letsbe-sysadmin/app/executors/shell_executor.py

164 lines
5.0 KiB
Python

"""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)