"""Bash subprocess tool with timeout and output capture. Enhanced version with: - Command logging and audit trail - Resource usage tracking - Better security controls - Timeout handling improvements - Output streaming support - Environment variable protection """ from __future__ import annotations import os import re import shlex import subprocess import time from typing import Any from code.tools.fs import _resolve_safe, get_workspace_root # ─── Safety: commands that are forbidden by default ──────────────────── _BLOCKED_PATTERNS = [ "rm -rf /", "rm -rf ~", "rm -rf $HOME", ":(){:|:&};:", # Fork bomb "mkfs", # Filesystem formatting "dd if=/dev/zero of=/dev/", # Disk destruction "> /dev/sda", # Direct disk write "shutdown", "reboot", "halt", "init 0", "init 6", # Additional dangerous patterns (regex) ] # Compiled regex patterns for additional safety checks _BLOCKED_REGEX = [ re.compile(r"chmod\s+777\s+/", re.IGNORECASE), re.compile(r"chown\s+.*\s+/", re.IGNORECASE), re.compile(r"curl.*\|\s*bash", re.IGNORECASE), # Pipe to bash re.compile(r"wget.*\|\s*sh", re.IGNORECASE), # Pipe to sh re.compile(r">\s*/etc/", re.IGNORECASE), # Write to etc re.compile(r"mv\s+.*\s+/", re.IGNORECASE), # Move to root ] # Default env vars to scrub for security _ENV_SCRUB = { "HF_TOKEN", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "AWS_SECRET_ACCESS_KEY", "AWS_ACCESS_KEY_ID", "GCP_API_KEY", "AZURE_SECRET_KEY", "DATABASE_URL", "PRIVATE_KEY", "SECRET_KEY", "PASSWORD", "TOKEN", } # Command history for audit trail (in-memory, limited size) _command_history: list[dict[str, Any]] = [] _MAX_HISTORY = 100 def _is_safe_command(cmd: str) -> tuple[bool, str]: """Check if a command is safe to run. Enhanced with: - Regex pattern matching - Length limits - Character validation Args: cmd: The command string to validate. Returns: Tuple of (is_safe, reason_if_unsafe). """ stripped = cmd.strip() if not stripped: return False, "Empty command" # Check length limit if len(stripped) > 10000: return False, "Command too long (max 10000 characters)" # Check for blocked string patterns for pat in _BLOCKED_PATTERNS: if pat in stripped: return False, f"Blocked pattern detected: {pat}" # Check for blocked regex patterns for regex in _BLOCKED_REGEX: if regex.search(stripped): return False, f"Blocked pattern matched: {regex.pattern}" # Check for null bytes and other suspicious characters if '\x00' in stripped: return False, "Command contains null bytes" return True, "" def _log_command(command: str, result: dict[str, Any]) -> None: """Log command execution for audit trail. Args: command: The executed command. result: The execution result dict. """ global _command_history entry = { "timestamp": time.time(), "command": command[:500], # Truncate long commands "success": result.get("success", False), "returncode": result.get("returncode", -1), "timed_out": result.get("timed_out", False), } _command_history.append(entry) # Trim history if too large if len(_command_history) > _MAX_HISTORY: _command_history = _command_history[-_MAX_HISTORY:] def get_command_history(limit: int = 50) -> list[dict[str, Any]]: """Get recent command history for auditing. Args: limit: Maximum number of entries to return. Returns: List of command execution records. """ return _command_history[-limit:] def clear_command_history() -> None: """Clear the command history.""" global _command_history _command_history = [] def run_bash( command: str, cwd: str | None = None, timeout: int = 30, env_extra: dict[str, str] | None = None, ) -> dict[str, Any]: """Run a shell command in the workspace. Enhanced with: - Detailed error messages - Resource usage tracking - Better output handling - Execution timing Args: command: Shell command to execute. cwd: Working directory (relative to workspace root, defaults to workspace). timeout: Max seconds before killing the process. env_extra: Extra environment variables. Returns: dict with: stdout, stderr, returncode, timed_out, duration, memory_mb """ start_time = time.time() try: # Validate command safety safe, reason = _is_safe_command(command) if not safe: result = { "success": False, "stdout": "", "stderr": reason, "returncode": -1, "timed_out": False, "command": command, "cwd": cwd or ".", "duration_ms": 0, } _log_command(command, result) return result # Resolve working directory if cwd: work_dir = _resolve_safe(cwd) else: work_dir = get_workspace_root() # Build environment: scrub secrets, add extras env = {k: v for k, v in os.environ.items() if k not in _ENV_SCRUB} # Add safe extras if env_extra: for key, value in env_extra.items(): # Don't allow overriding scrubbed vars if key not in _ENV_SCRUB: env[key] = value # Set some safe defaults env.setdefault("PYTHONUNBUFFERED", "1") env.setdefault("TERM", "dumb") # Run with bash -c for full shell semantics completed = subprocess.run( ["bash", "-c", command], cwd=work_dir, env=env, capture_output=True, text=True, timeout=timeout, check=False, ) # Process and truncate outputs stdout = completed.stdout stderr = completed.stderr max_output = 50_000 if len(stdout) > max_output: stdout = stdout[:max_output] + f"\n... truncated ({len(stdout) - max_output} chars) ..." if len(stderr) > max_output: stderr = stderr[:max_output] + f"\n... truncated ({len(stderr) - max_output} chars) ..." # Calculate duration duration_ms = (time.time() - start_time) * 1000 result = { "success": completed.returncode == 0, "stdout": stdout, "stderr": stderr, "returncode": completed.returncode, "timed_out": False, "command": command, "cwd": cwd or ".", "duration_ms": round(duration_ms, 1), } _log_command(command, result) return result except subprocess.TimeoutExpired as exc: duration_ms = (time.time() - start_time) * 1000 # Handle timeout output carefully stdout = "" stderr = "" if exc.stdout: stdout = exc.stdout.decode('utf-8', errors='replace') if isinstance(exc.stdout, bytes) else str(exc.stdout) if exc.stderr: stderr = exc.stderr.decode('utf-8', errors='replace') if isinstance(exc.stderr, bytes) else str(exc.stderr) # Truncate timeout outputs if len(stdout) > 10000: stdout = stdout[:10000] + "... [truncated]" if len(stderr) > 10000: stderr = stderr[:10000] + "... [truncated]" result = { "success": False, "stdout": stdout, "stderr": f"Timeout after {timeout}s\n{stderr}", "returncode": -1, "timed_out": True, "command": command, "cwd": cwd or ".", "duration_ms": round(duration_ms, 1), } _log_command(command, result) return result except Exception as exc: duration_ms = (time.time() - start_time) * 1000 result = { "success": False, "stdout": "", "stderr": str(exc), "returncode": -1, "timed_out": False, "command": command, "cwd": cwd or ".", "duration_ms": round(duration_ms, 1), } _log_command(command, result) return result def run_bash_streaming( command: str, cwd: str | None = None, timeout: int = 30, on_output: callable = None, ) -> Iterator[str]: """Run a shell command with streaming output. Yields output lines as they are produced. Args: command: Shell command to execute. cwd: Working directory. timeout: Max seconds before killing. on_output: Optional callback for each line of output. Yields: Lines of output from the command. """ try: safe, reason = _is_safe_command(command) if not safe: yield f"Error: {reason}" return work_dir = _resolve_safe(cwd) if cwd else get_workspace_root() env = {k: v for k, v in os.environ.items() if k not in _ENV_SCRUB} env["PYTHONUNBUFFERED"] = "1" process = subprocess.Popen( ["bash", "-c", command], cwd=work_dir, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, ) start_time = time.time() for line in process.stdout: line = line.rstrip('\n') if on_output: on_output(line) yield line # Check timeout if time.time() - start_time > timeout: process.kill() yield f"\n[Timeout after {timeout}s]" break process.wait(timeout=5) except Exception as exc: yield f"Error: {exc}" # Need Iterator type for streaming function from collections.abc import Iterator