turboclaude_skills/
executor.rs

1//! Script execution for skills
2//!
3//! Provides safe execution of Python and Bash scripts with timeout handling,
4//! output capture, and error management.
5//!
6//! # Example
7//!
8//! ```no_run
9//! use turboclaude_skills::executor::{PythonExecutor, ScriptExecutor};
10//! use std::path::Path;
11//! use std::time::Duration;
12//!
13//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
14//! let executor = PythonExecutor::new();
15//! let output = executor.execute(
16//!     Path::new("script.py"),
17//!     &["arg1", "arg2"],
18//!     Duration::from_secs(30),
19//! ).await?;
20//!
21//! if output.success() {
22//!     println!("Output: {}", output.stdout);
23//! } else {
24//!     eprintln!("Error: {}", output.stderr);
25//! }
26//! # Ok(())
27//! # }
28//! ```
29
30use crate::error::{Result, SkillError};
31use async_trait::async_trait;
32use std::path::{Path, PathBuf};
33use std::process::Stdio;
34use std::time::{Duration, Instant};
35use tokio::io::AsyncReadExt;
36use tokio::process::Command;
37
38/// Validates script paths to prevent directory traversal attacks
39///
40/// Ensures script paths are:
41/// - Absolute or properly canonicalized
42/// - Within an expected base directory
43/// - Not symlinks (optional, configurable)
44#[derive(Debug, Clone)]
45pub struct PathValidator {
46    base_dir: PathBuf,
47    allow_symlinks: bool,
48}
49
50impl PathValidator {
51    /// Create a new path validator with a base directory
52    ///
53    /// # Arguments
54    ///
55    /// * `base_dir` - The base directory that scripts must be under
56    pub fn new(base_dir: impl Into<PathBuf>) -> Self {
57        Self {
58            base_dir: base_dir.into(),
59            allow_symlinks: false,
60        }
61    }
62
63    /// Allow symlinks (default: false)
64    ///
65    /// By default, symlinks are rejected for security. Set this to true
66    /// to allow scripts to be symlinks (use with caution).
67    #[must_use]
68    pub fn allow_symlinks(mut self, allow: bool) -> Self {
69        self.allow_symlinks = allow;
70        self
71    }
72
73    /// Validate a script path
74    ///
75    /// Checks that:
76    /// 1. The path exists and is a file
77    /// 2. The canonical path is within `base_dir`
78    /// 3. The path is not a symlink (unless allowed)
79    ///
80    /// # Errors
81    ///
82    /// Returns error if path is invalid, outside base directory, or is a disallowed symlink
83    pub fn validate(&self, path: &Path) -> Result<PathBuf> {
84        // Check if path exists
85        if !path.exists() {
86            return Err(SkillError::ScriptExecution(format!(
87                "Script path does not exist: {}",
88                path.display()
89            )));
90        }
91
92        // Check if it's a file
93        if !path.is_file() {
94            return Err(SkillError::ScriptExecution(format!(
95                "Script path is not a regular file: {}",
96                path.display()
97            )));
98        }
99
100        // Check symlink policy
101        if path.is_symlink() && !self.allow_symlinks {
102            return Err(SkillError::ScriptExecution(format!(
103                "Symlinks are not allowed: {}",
104                path.display()
105            )));
106        }
107
108        // Canonicalize both paths for comparison
109        let canonical_path = path.canonicalize().map_err(|e| {
110            SkillError::ScriptExecution(format!("Failed to canonicalize script path: {e}"))
111        })?;
112
113        let canonical_base = self.base_dir.canonicalize().map_err(|e| {
114            SkillError::ScriptExecution(format!("Failed to canonicalize base directory: {e}"))
115        })?;
116
117        // Check that canonical path is within base directory
118        if !canonical_path.starts_with(&canonical_base) {
119            return Err(SkillError::ScriptExecution(format!(
120                "Script path is outside allowed directory: {}",
121                path.display()
122            )));
123        }
124
125        Ok(canonical_path)
126    }
127}
128
129/// Result of script execution
130///
131/// Contains all output from the script including stdout, stderr, exit code,
132/// timing information, and timeout status.
133#[derive(Debug, Clone)]
134pub struct ScriptOutput {
135    /// Exit code (0 = success)
136    pub exit_code: i32,
137
138    /// Standard output
139    pub stdout: String,
140
141    /// Standard error
142    pub stderr: String,
143
144    /// Execution duration
145    pub duration: Duration,
146
147    /// Whether the script timed out
148    pub timed_out: bool,
149}
150
151impl ScriptOutput {
152    /// Check if script executed successfully
153    ///
154    /// Returns true only if exit code is 0 and script did not timeout.
155    #[must_use]
156    pub fn success(&self) -> bool {
157        self.exit_code == 0 && !self.timed_out
158    }
159}
160
161/// Trait for script executors
162///
163/// Implementors can execute scripts in different languages (Python, Bash, etc.)
164/// with timeout handling and output capture.
165#[async_trait]
166pub trait ScriptExecutor: Send + Sync {
167    /// Execute a script with arguments and timeout
168    ///
169    /// # Arguments
170    ///
171    /// * `path` - Path to the script file
172    /// * `args` - Command-line arguments to pass to the script
173    /// * `timeout` - Maximum execution time before killing the process
174    ///
175    /// # Returns
176    ///
177    /// `ScriptOutput` containing exit code, stdout, stderr, duration, and timeout status
178    ///
179    /// # Errors
180    ///
181    /// Returns error if the script cannot be spawned or executed
182    async fn execute(&self, path: &Path, args: &[&str], timeout: Duration) -> Result<ScriptOutput>;
183
184    /// Check if this executor can handle the given file
185    ///
186    /// Typically checks file extension (.py for Python, .sh for Bash, etc.)
187    fn can_execute(&self, path: &Path) -> bool;
188}
189
190/// Python script executor
191///
192/// Executes Python scripts using the python3 interpreter.
193pub struct PythonExecutor {
194    /// Python interpreter path
195    python_path: String,
196    /// Optional path validator for security
197    path_validator: Option<PathValidator>,
198}
199
200impl PythonExecutor {
201    /// Create a new Python executor with default path ("python3")
202    #[must_use]
203    pub fn new() -> Self {
204        Self {
205            python_path: "python3".to_string(),
206            path_validator: None,
207        }
208    }
209
210    /// Create with custom Python interpreter path
211    ///
212    /// # Example
213    ///
214    /// ```
215    /// use turboclaude_skills::executor::PythonExecutor;
216    ///
217    /// let executor = PythonExecutor::with_path("/usr/local/bin/python3.11");
218    /// ```
219    #[must_use]
220    pub fn with_path(python_path: impl Into<String>) -> Self {
221        Self {
222            python_path: python_path.into(),
223            path_validator: None,
224        }
225    }
226
227    /// Set a path validator for security
228    ///
229    /// When set, all script paths will be validated against the base directory.
230    /// This prevents directory traversal attacks.
231    ///
232    /// # Example
233    ///
234    /// ```
235    /// use turboclaude_skills::executor::{PythonExecutor, PathValidator};
236    /// use std::path::PathBuf;
237    ///
238    /// let validator = PathValidator::new("/home/user/scripts");
239    /// let executor = PythonExecutor::new().with_validator(validator);
240    /// ```
241    #[must_use]
242    pub fn with_validator(mut self, validator: PathValidator) -> Self {
243        self.path_validator = Some(validator);
244        self
245    }
246}
247
248impl Default for PythonExecutor {
249    fn default() -> Self {
250        Self::new()
251    }
252}
253
254#[async_trait]
255impl ScriptExecutor for PythonExecutor {
256    async fn execute(
257        &self,
258        path: &Path,
259        args: &[&str],
260        timeout_duration: Duration,
261    ) -> Result<ScriptOutput> {
262        let start = Instant::now();
263
264        // Validate path if validator is configured
265        if let Some(validator) = &self.path_validator {
266            validator.validate(path)?;
267        }
268
269        // Build command
270        let mut cmd = Command::new(&self.python_path);
271        cmd.arg(path);
272        cmd.args(args);
273        cmd.stdout(Stdio::piped());
274        cmd.stderr(Stdio::piped());
275
276        // Spawn process with kill_on_drop to ensure cleanup
277        let mut child = cmd
278            .kill_on_drop(true)
279            .spawn()
280            .map_err(|e| SkillError::ScriptExecution(format!("Failed to spawn Python: {e}")))?;
281
282        let child_id = child.id();
283
284        // Manually capture stdout/stderr while monitoring for timeout
285        // We need to read output concurrently to avoid deadlocks
286        let mut stdout_handle = child.stdout.take().unwrap();
287        let mut stderr_handle = child.stderr.take().unwrap();
288
289        let stdout_task = tokio::spawn(async move {
290            let mut buf = Vec::new();
291            stdout_handle.read_to_end(&mut buf).await.ok();
292            buf
293        });
294
295        let stderr_task = tokio::spawn(async move {
296            let mut buf = Vec::new();
297            stderr_handle.read_to_end(&mut buf).await.ok();
298            buf
299        });
300
301        // Use tokio::select! to handle timeout with proper kill
302        let result = tokio::select! {
303            status_result = child.wait() => {
304                let duration = start.elapsed();
305                match status_result {
306                    Ok(status) => {
307                        // Get output from background tasks
308                        let stdout_buf = stdout_task.await.unwrap_or_default();
309                        let stderr_buf = stderr_task.await.unwrap_or_default();
310
311                        Ok(ScriptOutput {
312                            exit_code: status.code().unwrap_or(-1),
313                            stdout: String::from_utf8_lossy(&stdout_buf).to_string(),
314                            stderr: String::from_utf8_lossy(&stderr_buf).to_string(),
315                            duration,
316                            timed_out: false,
317                        })
318                    }
319                    Err(e) => Err(SkillError::ScriptExecution(format!(
320                        "Python execution failed: {e}"
321                    ))),
322                }
323            }
324
325            () = tokio::time::sleep(timeout_duration) => {
326                // Timeout - kill the process explicitly
327                if let Err(e) = child.kill().await {
328                    tracing::warn!(
329                        "Failed to kill timed-out Python process {}: {}",
330                        child_id.unwrap_or(0),
331                        e
332                    );
333                }
334
335                // Abort background tasks since we're timing out
336                stdout_task.abort();
337                stderr_task.abort();
338
339                let duration = start.elapsed();
340                Ok(ScriptOutput {
341                    exit_code: -1,
342                    stdout: String::new(),
343                    stderr: format!("Script timed out after {timeout_duration:?}"),
344                    duration,
345                    timed_out: true,
346                })
347            }
348        };
349
350        result
351    }
352
353    fn can_execute(&self, path: &Path) -> bool {
354        path.extension()
355            .and_then(|ext| ext.to_str())
356            .is_some_and(|ext| ext == "py")
357    }
358}
359
360/// Bash script executor
361///
362/// Executes Bash scripts using the bash interpreter.
363pub struct BashExecutor {
364    /// Bash interpreter path
365    bash_path: String,
366    /// Optional path validator for security
367    path_validator: Option<PathValidator>,
368}
369
370impl BashExecutor {
371    /// Create a new Bash executor with default path ("bash")
372    #[must_use]
373    pub fn new() -> Self {
374        Self {
375            bash_path: "bash".to_string(),
376            path_validator: None,
377        }
378    }
379
380    /// Create with custom Bash interpreter path
381    ///
382    /// # Example
383    ///
384    /// ```
385    /// use turboclaude_skills::executor::BashExecutor;
386    ///
387    /// let executor = BashExecutor::with_path("/bin/bash");
388    /// ```
389    #[must_use]
390    pub fn with_path(bash_path: impl Into<String>) -> Self {
391        Self {
392            bash_path: bash_path.into(),
393            path_validator: None,
394        }
395    }
396
397    /// Set a path validator for security
398    ///
399    /// When set, all script paths will be validated against the base directory.
400    /// This prevents directory traversal attacks.
401    ///
402    /// # Example
403    ///
404    /// ```
405    /// use turboclaude_skills::executor::{BashExecutor, PathValidator};
406    /// use std::path::PathBuf;
407    ///
408    /// let validator = PathValidator::new("/home/user/scripts");
409    /// let executor = BashExecutor::new().with_validator(validator);
410    /// ```
411    #[must_use]
412    pub fn with_validator(mut self, validator: PathValidator) -> Self {
413        self.path_validator = Some(validator);
414        self
415    }
416}
417
418impl Default for BashExecutor {
419    fn default() -> Self {
420        Self::new()
421    }
422}
423
424#[async_trait]
425impl ScriptExecutor for BashExecutor {
426    async fn execute(
427        &self,
428        path: &Path,
429        args: &[&str],
430        timeout_duration: Duration,
431    ) -> Result<ScriptOutput> {
432        let start = Instant::now();
433
434        // Validate path if validator is configured
435        if let Some(validator) = &self.path_validator {
436            validator.validate(path)?;
437        }
438
439        // Build command
440        let mut cmd = Command::new(&self.bash_path);
441        cmd.arg(path);
442        cmd.args(args);
443        cmd.stdout(Stdio::piped());
444        cmd.stderr(Stdio::piped());
445
446        // Spawn process with kill_on_drop to ensure cleanup
447        let mut child = cmd
448            .kill_on_drop(true)
449            .spawn()
450            .map_err(|e| SkillError::ScriptExecution(format!("Failed to spawn Bash: {e}")))?;
451
452        let child_id = child.id();
453
454        // Manually capture stdout/stderr while monitoring for timeout
455        // We need to read output concurrently to avoid deadlocks
456        let mut stdout_handle = child.stdout.take().unwrap();
457        let mut stderr_handle = child.stderr.take().unwrap();
458
459        let stdout_task = tokio::spawn(async move {
460            let mut buf = Vec::new();
461            stdout_handle.read_to_end(&mut buf).await.ok();
462            buf
463        });
464
465        let stderr_task = tokio::spawn(async move {
466            let mut buf = Vec::new();
467            stderr_handle.read_to_end(&mut buf).await.ok();
468            buf
469        });
470
471        // Use tokio::select! to handle timeout with proper kill
472        let result = tokio::select! {
473            status_result = child.wait() => {
474                let duration = start.elapsed();
475                match status_result {
476                    Ok(status) => {
477                        // Get output from background tasks
478                        let stdout_buf = stdout_task.await.unwrap_or_default();
479                        let stderr_buf = stderr_task.await.unwrap_or_default();
480
481                        Ok(ScriptOutput {
482                            exit_code: status.code().unwrap_or(-1),
483                            stdout: String::from_utf8_lossy(&stdout_buf).to_string(),
484                            stderr: String::from_utf8_lossy(&stderr_buf).to_string(),
485                            duration,
486                            timed_out: false,
487                        })
488                    }
489                    Err(e) => Err(SkillError::ScriptExecution(format!(
490                        "Bash execution failed: {e}"
491                    ))),
492                }
493            }
494
495            () = tokio::time::sleep(timeout_duration) => {
496                // Timeout - kill the process explicitly
497                if let Err(e) = child.kill().await {
498                    tracing::warn!(
499                        "Failed to kill timed-out Bash process {}: {}",
500                        child_id.unwrap_or(0),
501                        e
502                    );
503                }
504
505                // Abort background tasks since we're timing out
506                stdout_task.abort();
507                stderr_task.abort();
508
509                let duration = start.elapsed();
510                Ok(ScriptOutput {
511                    exit_code: -1,
512                    stdout: String::new(),
513                    stderr: format!("Script timed out after {timeout_duration:?}"),
514                    duration,
515                    timed_out: true,
516                })
517            }
518        };
519
520        result
521    }
522
523    fn can_execute(&self, path: &Path) -> bool {
524        path.extension()
525            .and_then(|ext| ext.to_str())
526            .is_some_and(|ext| ext == "sh")
527    }
528}
529
530/// Composite executor that routes to the appropriate executor
531///
532/// Automatically selects the correct executor based on file extension.
533/// Default executors: Python (.py), Bash (.sh)
534pub struct CompositeExecutor {
535    executors: Vec<Box<dyn ScriptExecutor>>,
536}
537
538impl CompositeExecutor {
539    /// Create a new composite executor with default executors
540    ///
541    /// Default executors:
542    /// - `PythonExecutor` for .py files
543    /// - `BashExecutor` for .sh files
544    #[must_use]
545    pub fn new() -> Self {
546        Self {
547            executors: vec![
548                Box::new(PythonExecutor::new()),
549                Box::new(BashExecutor::new()),
550            ],
551        }
552    }
553
554    /// Create with custom executors
555    ///
556    /// # Example
557    ///
558    /// ```
559    /// use turboclaude_skills::executor::{CompositeExecutor, PythonExecutor, BashExecutor};
560    ///
561    /// let executor = CompositeExecutor::with_executors(vec![
562    ///     Box::new(PythonExecutor::with_path("/usr/bin/python3.11")),
563    ///     Box::new(BashExecutor::new()),
564    /// ]);
565    /// ```
566    #[must_use]
567    pub fn with_executors(executors: Vec<Box<dyn ScriptExecutor>>) -> Self {
568        Self { executors }
569    }
570}
571
572impl Default for CompositeExecutor {
573    fn default() -> Self {
574        Self::new()
575    }
576}
577
578#[async_trait]
579impl ScriptExecutor for CompositeExecutor {
580    async fn execute(&self, path: &Path, args: &[&str], timeout: Duration) -> Result<ScriptOutput> {
581        // Find appropriate executor
582        for executor in &self.executors {
583            if executor.can_execute(path) {
584                return executor.execute(path, args, timeout).await;
585            }
586        }
587
588        Err(SkillError::UnsupportedScriptType(
589            path.extension()
590                .and_then(|ext| ext.to_str())
591                .unwrap_or("unknown")
592                .to_string(),
593        ))
594    }
595
596    fn can_execute(&self, path: &Path) -> bool {
597        self.executors.iter().any(|e| e.can_execute(path))
598    }
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604
605    #[test]
606    fn test_path_validator_valid_path() {
607        let temp_dir = tempfile::tempdir().unwrap();
608        let script_file = temp_dir.path().join("script.py");
609        std::fs::write(&script_file, "print('hello')").unwrap();
610
611        let validator = PathValidator::new(temp_dir.path());
612        let result = validator.validate(&script_file);
613        assert!(result.is_ok());
614    }
615
616    #[test]
617    fn test_path_validator_nonexistent_path() {
618        let temp_dir = tempfile::tempdir().unwrap();
619        let nonexistent = temp_dir.path().join("nonexistent.py");
620
621        let validator = PathValidator::new(temp_dir.path());
622        let result = validator.validate(&nonexistent);
623        assert!(result.is_err());
624    }
625
626    #[test]
627    fn test_path_validator_directory() {
628        let temp_dir = tempfile::tempdir().unwrap();
629        let subdir = temp_dir.path().join("subdir");
630        std::fs::create_dir(&subdir).unwrap();
631
632        let validator = PathValidator::new(temp_dir.path());
633        let result = validator.validate(&subdir);
634        assert!(result.is_err());
635    }
636
637    #[test]
638    fn test_path_validator_traversal_attempt() {
639        let temp_dir = tempfile::tempdir().unwrap();
640        let scripts_dir = temp_dir.path().join("scripts");
641        std::fs::create_dir(&scripts_dir).unwrap();
642
643        let script_file = scripts_dir.join("script.py");
644        std::fs::write(&script_file, "print('hello')").unwrap();
645
646        let parent_dir = temp_dir.path().join("other");
647        std::fs::create_dir(&parent_dir).unwrap();
648
649        let validator = PathValidator::new(&scripts_dir);
650        // Try to access file outside scripts_dir via ..
651        let traversal_path = scripts_dir.join("../other");
652        let result = validator.validate(&traversal_path);
653        // Should fail because the canonical path is outside scripts_dir
654        assert!(result.is_err());
655    }
656
657    #[test]
658    fn test_python_executor_can_execute() {
659        let executor = PythonExecutor::new();
660        assert!(executor.can_execute(Path::new("script.py")));
661        assert!(!executor.can_execute(Path::new("script.sh")));
662        assert!(!executor.can_execute(Path::new("script.txt")));
663    }
664
665    #[test]
666    fn test_bash_executor_can_execute() {
667        let executor = BashExecutor::new();
668        assert!(executor.can_execute(Path::new("script.sh")));
669        assert!(!executor.can_execute(Path::new("script.py")));
670        assert!(!executor.can_execute(Path::new("script.txt")));
671    }
672
673    #[test]
674    fn test_composite_executor_can_execute() {
675        let executor = CompositeExecutor::new();
676        assert!(executor.can_execute(Path::new("script.py")));
677        assert!(executor.can_execute(Path::new("script.sh")));
678        assert!(!executor.can_execute(Path::new("script.txt")));
679    }
680
681    #[test]
682    fn test_script_output_success() {
683        let output = ScriptOutput {
684            exit_code: 0,
685            stdout: "Success".to_string(),
686            stderr: String::new(),
687            duration: Duration::from_millis(100),
688            timed_out: false,
689        };
690        assert!(output.success());
691
692        let failed = ScriptOutput {
693            exit_code: 1,
694            stdout: String::new(),
695            stderr: "Error".to_string(),
696            duration: Duration::from_millis(100),
697            timed_out: false,
698        };
699        assert!(!failed.success());
700
701        let timeout = ScriptOutput {
702            exit_code: 0,
703            stdout: String::new(),
704            stderr: String::new(),
705            duration: Duration::from_secs(30),
706            timed_out: true,
707        };
708        assert!(!timeout.success());
709    }
710
711    #[test]
712    fn test_python_executor_with_path() {
713        let executor = PythonExecutor::with_path("/usr/local/bin/python3.11");
714        assert_eq!(executor.python_path, "/usr/local/bin/python3.11");
715    }
716
717    #[test]
718    fn test_bash_executor_with_path() {
719        let executor = BashExecutor::with_path("/bin/bash");
720        assert_eq!(executor.bash_path, "/bin/bash");
721    }
722}