"""Shell command executor with strict security controls.""" import asyncio import time from typing import Any, Optional from app.config import get_settings from app.executors.base import BaseExecutor, ExecutionResult from app.utils.validation import ValidationError, validate_shell_command class ShellExecutor(BaseExecutor): """Execute shell commands with strict security controls. Security measures: - Absolute path allowlist for commands - Per-command argument validation via regex - Forbidden shell metacharacter blocking - No shell=True (prevents shell injection) - Timeout enforcement with watchdog - Runs via asyncio.to_thread to avoid blocking Payload: { "cmd": "/usr/bin/ls", # Must be absolute path "args": "-la /opt/data", # Optional arguments "timeout": 60 # Optional timeout override } Result: { "exit_code": 0, "stdout": "...", "stderr": "...", "duration_ms": 123.45 } """ @property def task_type(self) -> str: return "SHELL" async def execute(self, payload: dict[str, Any]) -> ExecutionResult: """Execute a shell command. Args: payload: Must contain "cmd", optionally "args" and "timeout" Returns: ExecutionResult with command output """ self.validate_payload(payload, ["cmd"]) settings = get_settings() cmd = payload["cmd"] args_str = payload.get("args", "") timeout_override = payload.get("timeout") # Validate command and arguments try: validated_cmd, args_list, default_timeout = validate_shell_command(cmd, args_str) except ValidationError as e: self.logger.warning("shell_validation_failed", cmd=cmd, error=str(e)) return ExecutionResult( success=False, data={"exit_code": -1, "stdout": "", "stderr": ""}, error=f"Validation failed: {e}", ) # Determine timeout timeout = timeout_override if timeout_override is not None else default_timeout timeout = min(timeout, settings.shell_timeout) # Cap at global max self.logger.info( "shell_executing", cmd=validated_cmd, args=args_list, timeout=timeout, ) start_time = time.time() try: # Run in thread pool to avoid blocking event loop result = await asyncio.wait_for( self._run_subprocess(validated_cmd, args_list), timeout=timeout * 2, # Watchdog at 2x timeout ) duration_ms = (time.time() - start_time) * 1000 exit_code, stdout, stderr = result success = exit_code == 0 self.logger.info( "shell_completed", cmd=validated_cmd, exit_code=exit_code, duration_ms=duration_ms, ) return ExecutionResult( success=success, data={ "exit_code": exit_code, "stdout": stdout, "stderr": stderr, }, error=stderr if not success else None, duration_ms=duration_ms, ) except asyncio.TimeoutError: duration_ms = (time.time() - start_time) * 1000 self.logger.error("shell_timeout", cmd=validated_cmd, timeout=timeout) return ExecutionResult( success=False, data={"exit_code": -1, "stdout": "", "stderr": ""}, error=f"Command timed out after {timeout} seconds", duration_ms=duration_ms, ) except Exception as e: duration_ms = (time.time() - start_time) * 1000 self.logger.error("shell_error", cmd=validated_cmd, error=str(e)) return ExecutionResult( success=False, data={"exit_code": -1, "stdout": "", "stderr": ""}, error=str(e), duration_ms=duration_ms, ) async def _run_subprocess( self, cmd: str, args: list[str], ) -> tuple[int, str, str]: """Run subprocess in thread pool. Args: cmd: Command to run (absolute path) args: Command arguments Returns: Tuple of (exit_code, stdout, stderr) """ import subprocess def _run() -> tuple[int, str, str]: # Build full command list full_cmd = [cmd] + args # Run WITHOUT shell=True for security result = subprocess.run( full_cmd, capture_output=True, text=True, timeout=get_settings().shell_timeout, ) return result.returncode, result.stdout, result.stderr return await asyncio.to_thread(_run)