Skip to main content

fastskill_core/
execution.rs

1//! Script execution environment with sandboxing support
2
3use 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/// Execution environment configuration
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct ExecutionConfig {
38    /// Default timeout for script execution
39    pub default_timeout: Duration,
40
41    /// Maximum memory per execution (MB)
42    pub max_memory_mb: usize,
43
44    /// Network access policy
45    pub network_policy: NetworkPolicy,
46
47    /// File system access level
48    pub filesystem_access: FileSystemAccess,
49
50    /// Allowed commands for shell execution
51    pub allowed_commands: Vec<String>,
52
53    /// Allowed script roots - scripts must be under one of these paths if configured
54    pub allowed_script_roots: Vec<PathBuf>,
55
56    /// Environment variables to set
57    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/// Network access policy
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub enum NetworkPolicy {
83    /// No network access allowed
84    None,
85    /// Only localhost allowed
86    Localhost,
87    /// Restricted to specific domains
88    Restricted { allowed_domains: Vec<String> },
89    /// Full network access
90    Full,
91}
92
93/// File system access levels
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub enum FileSystemAccess {
96    /// No file system access
97    None,
98    /// Only working directory
99    WorkingDirectory,
100    /// Read-only access to specific paths
101    ReadOnly { paths: Vec<PathBuf> },
102    /// Full access (dangerous)
103    Full,
104}
105
106/// Script definition for execution
107#[derive(Debug, Clone)]
108pub struct ScriptDefinition {
109    /// Path to the script file
110    pub path: PathBuf,
111
112    /// Script content (if not reading from file)
113    pub content: Option<String>,
114
115    /// Script language/interpreter
116    pub language: ScriptLanguage,
117
118    /// Execution parameters
119    pub parameters: HashMap<String, String>,
120
121    /// Working directory for execution
122    pub working_directory: Option<PathBuf>,
123}
124
125/// Supported script languages
126#[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/// Execution context
147#[derive(Debug, Clone)]
148pub struct ExecutionContext {
149    /// Skill ID that initiated the execution
150    pub skill_id: String,
151
152    /// User ID (if available)
153    pub user_id: Option<String>,
154
155    /// Session ID
156    pub session_id: String,
157
158    /// Input parameters for the execution
159    pub parameters: HashMap<String, String>,
160
161    /// Working directory override
162    pub working_directory: Option<PathBuf>,
163
164    /// Environment variables override
165    pub environment_variables: HashMap<String, String>,
166}
167
168/// Execution result
169#[derive(Debug, Clone)]
170pub struct ExecutionResult {
171    /// Whether execution was successful
172    pub success: bool,
173
174    /// Standard output from execution
175    pub stdout: String,
176
177    /// Standard error from execution
178    pub stderr: String,
179
180    /// Exit code (if applicable)
181    pub exit_code: Option<i32>,
182
183    /// Execution time taken
184    pub execution_time: Duration,
185
186    /// Resources used (approximate)
187    pub resources_used: ResourceUsage,
188}
189
190/// Resource usage tracking
191#[derive(Debug, Clone, Default)]
192pub struct ResourceUsage {
193    /// Memory used (MB)
194    pub memory_mb: f64,
195
196    /// CPU time used (seconds)
197    pub cpu_seconds: f64,
198}
199
200/// Security sandbox for script execution
201pub struct ExecutionSandbox {
202    config: ExecutionConfig,
203}
204
205impl ExecutionSandbox {
206    /// Create a new execution sandbox with the given configuration
207    pub fn new(config: ExecutionConfig) -> Result<Self, ExecutionError> {
208        Ok(Self { config })
209    }
210
211    /// Execute a script with the provided context
212    pub async fn execute_script(
213        &self,
214        script: ScriptDefinition,
215        context: ExecutionContext,
216    ) -> Result<ExecutionResult, ExecutionError> {
217        // Validate script for security
218        self.validate_script(&script)?;
219
220        let start_time = std::time::Instant::now();
221
222        // For now, fallback to user's environment execution
223        // In the future, this would use proper sandboxing
224        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(), // Would track actual usage in sandboxed version
235        })
236    }
237
238    /// Validate script for security issues
239    fn validate_script(&self, script: &ScriptDefinition) -> Result<(), ExecutionError> {
240        // Basic validation - check for dangerous patterns
241        if let Some(content) = &script.content {
242            // Check for dangerous imports or system calls
243            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        // Validate file paths if accessing files
268        if script.path.exists() {
269            // Check path traversal - ensure script is under allowed roots if configured
270            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    /// Execute script in user's environment (fallback implementation)
300    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        // Determine command based on script language
314        let command = match script.language {
315            ScriptLanguage::Python => "python3",
316            ScriptLanguage::NodeJS => "node",
317            ScriptLanguage::Shell => "sh",
318            ScriptLanguage::Rust => "cargo",
319        };
320
321        // Build the command
322        let mut cmd = TokioCommand::new(command);
323
324        // Add script path as argument
325        cmd.arg(script_path);
326
327        // Add parameters as environment variables
328        for (key, value) in &script.parameters {
329            cmd.env(format!("PARAM_{}", key), value);
330        }
331
332        // Add context environment variables
333        for (key, value) in &context.environment_variables {
334            cmd.env(key, value);
335        }
336
337        // Set working directory
338        if let Some(working_dir) = &script.working_directory {
339            cmd.current_dir(working_dir);
340        }
341
342        // Set timeout
343        let timeout_duration = self.config.default_timeout;
344
345        // Execute with timeout
346        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    /// Execute a command directly
369    pub async fn execute_command(
370        &self,
371        command: String,
372        args: Vec<String>,
373        context: ExecutionContext,
374    ) -> Result<ExecutionResult, ExecutionError> {
375        // Check if command is allowed
376        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        // Execute command in user's environment
393        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    /// Execute command in user's environment (fallback implementation)
410    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        // Set working directory
420        if let Some(working_dir) = &context.working_directory {
421            cmd.current_dir(working_dir);
422        }
423
424        // Set timeout
425        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    /// Get current configuration
450    pub fn config(&self) -> &ExecutionConfig {
451        &self.config
452    }
453}
454
455/// Result from user environment execution
456#[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        // Create a script outside the allowed root
483        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        // Create a script inside the allowed root
513        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}