1use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::PathBuf;
6use std::process::Stdio;
7use std::time::Duration;
8use tokio::process::Command as TokioCommand;
9use tokio::time::timeout;
10
11#[derive(Debug, thiserror::Error)]
12pub enum ExecutionError {
13 #[error("Execution failed: {0}")]
14 Failed(String),
15
16 #[error("Script not found: {0}")]
17 ScriptNotFound(String),
18
19 #[error("Invalid script content: {0}")]
20 InvalidScript(String),
21
22 #[error("Execution timeout after {0:?}")]
23 Timeout(Duration),
24
25 #[error("Resource limit exceeded: {0}")]
26 ResourceExceeded(String),
27
28 #[error("Security violation: {0}")]
29 SecurityViolation(String),
30
31 #[error("IO error: {0}")]
32 Io(#[from] std::io::Error),
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct ExecutionConfig {
38 pub default_timeout: Duration,
40
41 pub max_memory_mb: usize,
43
44 pub network_policy: NetworkPolicy,
46
47 pub filesystem_access: FileSystemAccess,
49
50 pub allowed_commands: Vec<String>,
52
53 pub allowed_script_roots: Vec<PathBuf>,
55
56 pub environment_variables: HashMap<String, String>,
58}
59
60impl Default for ExecutionConfig {
61 fn default() -> Self {
62 Self {
63 default_timeout: Duration::from_secs(30),
64 max_memory_mb: 100,
65 network_policy: NetworkPolicy::Restricted {
66 allowed_domains: vec![],
67 },
68 filesystem_access: FileSystemAccess::WorkingDirectory,
69 allowed_commands: vec![
70 "python".to_string(),
71 "python3".to_string(),
72 "node".to_string(),
73 ],
74 allowed_script_roots: Vec::new(),
75 environment_variables: HashMap::new(),
76 }
77 }
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
82pub enum NetworkPolicy {
83 None,
85 Localhost,
87 Restricted { allowed_domains: Vec<String> },
89 Full,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
95pub enum FileSystemAccess {
96 None,
98 WorkingDirectory,
100 ReadOnly { paths: Vec<PathBuf> },
102 Full,
104}
105
106#[derive(Debug, Clone)]
108pub struct ScriptDefinition {
109 pub path: PathBuf,
111
112 pub content: Option<String>,
114
115 pub language: ScriptLanguage,
117
118 pub parameters: HashMap<String, String>,
120
121 pub working_directory: Option<PathBuf>,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
127pub enum ScriptLanguage {
128 Python,
129 NodeJS,
130 Shell,
131 Rust,
132}
133
134impl ScriptLanguage {
135 #[allow(dead_code)]
136 fn get_command(&self) -> &'static str {
137 match self {
138 ScriptLanguage::Python => "python3",
139 ScriptLanguage::NodeJS => "node",
140 ScriptLanguage::Shell => "sh",
141 ScriptLanguage::Rust => "cargo",
142 }
143 }
144}
145
146#[derive(Debug, Clone)]
148pub struct ExecutionContext {
149 pub skill_id: String,
151
152 pub user_id: Option<String>,
154
155 pub session_id: String,
157
158 pub parameters: HashMap<String, String>,
160
161 pub working_directory: Option<PathBuf>,
163
164 pub environment_variables: HashMap<String, String>,
166}
167
168#[derive(Debug, Clone)]
170pub struct ExecutionResult {
171 pub success: bool,
173
174 pub stdout: String,
176
177 pub stderr: String,
179
180 pub exit_code: Option<i32>,
182
183 pub execution_time: Duration,
185
186 pub resources_used: ResourceUsage,
188}
189
190#[derive(Debug, Clone, Default)]
192pub struct ResourceUsage {
193 pub memory_mb: f64,
195
196 pub cpu_seconds: f64,
198}
199
200pub struct ExecutionSandbox {
202 config: ExecutionConfig,
203}
204
205impl ExecutionSandbox {
206 pub fn new(config: ExecutionConfig) -> Result<Self, ExecutionError> {
208 Ok(Self { config })
209 }
210
211 pub async fn execute_script(
213 &self,
214 script: ScriptDefinition,
215 context: ExecutionContext,
216 ) -> Result<ExecutionResult, ExecutionError> {
217 self.validate_script(&script)?;
219
220 let start_time = std::time::Instant::now();
221
222 let result = self.execute_in_user_environment(script, context).await?;
225
226 let execution_time = start_time.elapsed();
227
228 Ok(ExecutionResult {
229 success: result.exit_code.unwrap_or(0) == 0,
230 stdout: result.stdout,
231 stderr: result.stderr,
232 exit_code: result.exit_code,
233 execution_time,
234 resources_used: ResourceUsage::default(), })
236 }
237
238 fn validate_script(&self, script: &ScriptDefinition) -> Result<(), ExecutionError> {
240 if let Some(content) = &script.content {
242 let dangerous_patterns = [
244 "import os",
245 "import subprocess",
246 "import sys",
247 "exec(",
248 "eval(",
249 "system(",
250 "popen(",
251 "rm -rf",
252 "sudo",
253 "chmod 777",
254 "chown",
255 ];
256
257 for pattern in &dangerous_patterns {
258 if content.contains(pattern) {
259 return Err(ExecutionError::SecurityViolation(format!(
260 "Dangerous pattern detected: {}",
261 pattern
262 )));
263 }
264 }
265 }
266
267 if script.path.exists() {
269 if !self.config.allowed_script_roots.is_empty() {
271 let canonical_path = script.path.canonicalize().map_err(|e| {
272 ExecutionError::SecurityViolation(format!(
273 "Failed to canonicalize script path: {}",
274 e
275 ))
276 })?;
277
278 let is_allowed = self.config.allowed_script_roots.iter().any(|root| {
279 if let Ok(canonical_root) = root.canonicalize() {
280 canonical_path.starts_with(&canonical_root)
281 } else {
282 false
283 }
284 });
285
286 if !is_allowed {
287 return Err(ExecutionError::SecurityViolation(format!(
288 "Script path '{}' is outside allowed roots: {:?}",
289 script.path.display(),
290 self.config.allowed_script_roots
291 )));
292 }
293 }
294 }
295
296 Ok(())
297 }
298
299 async fn execute_in_user_environment(
301 &self,
302 script: ScriptDefinition,
303 context: ExecutionContext,
304 ) -> Result<UserExecutionResult, ExecutionError> {
305 let script_path = &script.path;
306
307 if !script_path.exists() {
308 return Err(ExecutionError::ScriptNotFound(
309 script_path.to_string_lossy().to_string(),
310 ));
311 }
312
313 let command = match script.language {
315 ScriptLanguage::Python => "python3",
316 ScriptLanguage::NodeJS => "node",
317 ScriptLanguage::Shell => "sh",
318 ScriptLanguage::Rust => "cargo",
319 };
320
321 let mut cmd = TokioCommand::new(command);
323
324 cmd.arg(script_path);
326
327 for (key, value) in &script.parameters {
329 cmd.env(format!("PARAM_{}", key), value);
330 }
331
332 for (key, value) in &context.environment_variables {
334 cmd.env(key, value);
335 }
336
337 if let Some(working_dir) = &script.working_directory {
339 cmd.current_dir(working_dir);
340 }
341
342 let timeout_duration = self.config.default_timeout;
344
345 let result = timeout(timeout_duration, async {
347 let output = cmd
348 .stdout(Stdio::piped())
349 .stderr(Stdio::piped())
350 .output()
351 .await?;
352
353 Ok::<_, std::io::Error>(output)
354 })
355 .await;
356
357 match result {
358 Ok(Ok(output)) => Ok(UserExecutionResult {
359 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
360 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
361 exit_code: output.status.code(),
362 }),
363 Ok(Err(e)) => Err(ExecutionError::Io(e)),
364 Err(_) => Err(ExecutionError::Timeout(timeout_duration)),
365 }
366 }
367
368 pub async fn execute_command(
370 &self,
371 command: String,
372 args: Vec<String>,
373 context: ExecutionContext,
374 ) -> Result<ExecutionResult, ExecutionError> {
375 if !self.config.allowed_commands.is_empty() {
377 let command_name = command.split('/').next_back().unwrap_or(&command);
378 if !self
379 .config
380 .allowed_commands
381 .contains(&command_name.to_string())
382 {
383 return Err(ExecutionError::SecurityViolation(format!(
384 "Command '{}' is not in allowed commands list",
385 command_name
386 )));
387 }
388 }
389
390 let start_time = std::time::Instant::now();
391
392 let result = self
394 .execute_command_in_user_environment(command, args, context)
395 .await?;
396
397 let execution_time = start_time.elapsed();
398
399 Ok(ExecutionResult {
400 success: result.exit_code.unwrap_or(0) == 0,
401 stdout: result.stdout,
402 stderr: result.stderr,
403 exit_code: result.exit_code,
404 execution_time,
405 resources_used: ResourceUsage::default(),
406 })
407 }
408
409 async fn execute_command_in_user_environment(
411 &self,
412 command: String,
413 args: Vec<String>,
414 context: ExecutionContext,
415 ) -> Result<UserExecutionResult, ExecutionError> {
416 let mut cmd = TokioCommand::new(command);
417 cmd.args(args);
418
419 if let Some(working_dir) = &context.working_directory {
421 cmd.current_dir(working_dir);
422 }
423
424 let timeout_duration = self.config.default_timeout;
426
427 let result = timeout(timeout_duration, async {
428 let output = cmd
429 .stdout(Stdio::piped())
430 .stderr(Stdio::piped())
431 .output()
432 .await?;
433
434 Ok::<_, std::io::Error>(output)
435 })
436 .await;
437
438 match result {
439 Ok(Ok(output)) => Ok(UserExecutionResult {
440 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
441 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
442 exit_code: output.status.code(),
443 }),
444 Ok(Err(e)) => Err(ExecutionError::Io(e)),
445 Err(_) => Err(ExecutionError::Timeout(timeout_duration)),
446 }
447 }
448
449 pub fn config(&self) -> &ExecutionConfig {
451 &self.config
452 }
453}
454
455#[derive(Debug)]
457struct UserExecutionResult {
458 stdout: String,
459 stderr: String,
460 exit_code: Option<i32>,
461}
462
463#[cfg(test)]
464#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
465mod tests {
466 use super::*;
467 use tempfile::TempDir;
468
469 #[test]
470 fn test_validate_script_rejects_path_traversal() {
471 let temp_dir = TempDir::new().unwrap();
472 let allowed_root = temp_dir.path().join("allowed");
473 std::fs::create_dir_all(&allowed_root).unwrap();
474
475 let config = ExecutionConfig {
476 allowed_script_roots: vec![allowed_root.clone()],
477 ..Default::default()
478 };
479
480 let sandbox = ExecutionSandbox::new(config).unwrap();
481
482 let outside_path = temp_dir.path().join("outside").join("script.py");
484 std::fs::create_dir_all(outside_path.parent().unwrap()).unwrap();
485 std::fs::write(&outside_path, "print('test')").unwrap();
486
487 let script = ScriptDefinition {
488 path: outside_path,
489 content: None,
490 language: ScriptLanguage::Python,
491 parameters: std::collections::HashMap::new(),
492 working_directory: None,
493 };
494
495 let result = sandbox.validate_script(&script);
496 assert!(matches!(result, Err(ExecutionError::SecurityViolation(_))));
497 }
498
499 #[test]
500 fn test_validate_script_accepts_path_inside_allowed_root() {
501 let temp_dir = TempDir::new().unwrap();
502 let allowed_root = temp_dir.path().join("allowed");
503 std::fs::create_dir_all(&allowed_root).unwrap();
504
505 let config = ExecutionConfig {
506 allowed_script_roots: vec![allowed_root.clone()],
507 ..Default::default()
508 };
509
510 let sandbox = ExecutionSandbox::new(config).unwrap();
511
512 let inside_path = allowed_root.join("script.py");
514 std::fs::write(&inside_path, "print('test')").unwrap();
515
516 let script = ScriptDefinition {
517 path: inside_path,
518 content: None,
519 language: ScriptLanguage::Python,
520 parameters: std::collections::HashMap::new(),
521 working_directory: None,
522 };
523
524 let result = sandbox.validate_script(&script);
525 assert!(result.is_ok());
526 }
527}