""" Read-only bash command execution tool. Only allows safe, read-only commands to prevent accidental damage. """ import asyncio import shlex from pathlib import Path from src.domains.tools.base import BaseTool, ToolResult from src.shared.logging import logged, get_logger logger = get_logger(__name__) # Commands that are allowed in read-only mode ALLOWED_COMMANDS = { # File inspection "ls", "find", "cat", "head", "tail", "wc", "file", "stat", "tree", "du", "df", # Text processing (read-only) "grep", "awk", "sed", "sort", "uniq", "cut", "tr", # Git (read-only operations) "git", # System info "pwd", "whoami", "hostname", "uname", "date", "env", "printenv", "which", "type", "echo", # Archive inspection "tar", "unzip", "zipinfo", } # Git subcommands that are allowed (read-only) ALLOWED_GIT_SUBCOMMANDS = { "status", "log", "diff", "show", "branch", "tag", "remote", "config", "ls-files", "ls-tree", "rev-parse", "describe", "shortlog", "blame", } # Patterns that are never allowed (security) FORBIDDEN_PATTERNS = [ # Destructive redirects ">", ">>", # Command chaining (could bypass checks) "&&", "||", ";", # Subshells "$(", "`", # Explicit destructive commands "rm ", "rm\t", "rmdir", "mv ", "mv\t", "cp ", "cp\t", "mkdir", "touch", # Package managers "pip", "npm", "yarn", "apt", "yum", "brew", # Network "curl", "wget", "ssh", "scp", # Process control "kill", "pkill", "killall", ] class BashReadOnlyTool(BaseTool): """ Execute read-only bash commands safely. Only allows a curated set of commands that cannot modify the filesystem. """ name = "bash_readonly" description = """Execute a read-only bash command. ALLOWED commands: - File inspection: ls, find, cat, head, tail, wc, file, stat, tree, du - Git (read-only): git status, git log, git diff, git show, git branch - Text processing: grep, awk, sed (read-only), sort, uniq, cut - System info: pwd, whoami, hostname, uname, date, which FORBIDDEN: - Any file modification (rm, mv, cp, mkdir, touch) - Redirects (>, >>) - Command chaining (&&, ||, ;) - Package managers (pip, npm, apt) - Network commands (curl, wget, ssh) Args: command: The bash command to execute cwd: Working directory for the command (default: current directory) timeout: Timeout in seconds (default: 30) Returns: Command stdout on success, or error message. Examples: - "ls -la" - List files with details - "git status" - Show git status - "find . -name '*.py' -type f" - Find Python files - "head -50 README.md" - First 50 lines of README """ def __init__( self, allowed_paths: list[str] | None = None, default_timeout: int = 30, max_output_size: int = 50000 ): """ Initialize BashReadOnlyTool. Args: allowed_paths: Allowed working directories default_timeout: Default command timeout in seconds max_output_size: Maximum output size in characters """ self.allowed_paths = allowed_paths or [] self.default_timeout = default_timeout self.max_output_size = max_output_size @logged() async def execute( self, command: str, cwd: str | None = None, timeout: int | None = None ) -> ToolResult: """ Execute a read-only bash command. Args: command: Command to execute cwd: Working directory timeout: Timeout in seconds Returns: ToolResult with command output or error """ timeout = timeout or self.default_timeout working_dir = Path(cwd) if cwd else Path.cwd() # Validate working directory if not self._validate_path(working_dir, self.allowed_paths): return self._error(f"Working directory not allowed: {working_dir}") if not working_dir.exists(): return self._error(f"Working directory not found: {working_dir}") # Security validation validation_error = self._validate_command(command) if validation_error: return self._error(validation_error) try: proc = await asyncio.create_subprocess_shell( command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=str(working_dir) ) stdout, stderr = await asyncio.wait_for( proc.communicate(), timeout=timeout ) stdout_str = stdout.decode('utf-8', errors='replace') stderr_str = stderr.decode('utf-8', errors='replace') # Truncate if necessary truncated = False if len(stdout_str) > self.max_output_size: stdout_str = stdout_str[:self.max_output_size] truncated = True if proc.returncode != 0: # Command failed, return stderr error_msg = stderr_str or f"Command exited with code {proc.returncode}" return ToolResult( success=False, data=stdout_str if stdout_str else None, error=error_msg, truncated=truncated ) # Success - combine stdout and stderr if both present output = stdout_str if stderr_str and not output: output = stderr_str return self._success( data=output, truncated=truncated, exit_code=proc.returncode ) except asyncio.TimeoutError: return self._error(f"Command timed out after {timeout} seconds") except Exception as e: logger.exception(f"Error executing command: {command}") return self._error(f"Error executing command: {e}") def _validate_command(self, command: str) -> str | None: """ Validate command is safe to execute. Returns: Error message if invalid, None if valid """ # Check for forbidden patterns command_lower = command.lower() for pattern in FORBIDDEN_PATTERNS: if pattern in command_lower: return f"Command contains forbidden pattern: {pattern.strip()}" # Parse command to get base command try: tokens = shlex.split(command) if not tokens: return "Empty command" except ValueError as e: return f"Invalid command syntax: {e}" # Get base command (handle full paths) base_cmd = Path(tokens[0]).name # Check if command is allowed if base_cmd not in ALLOWED_COMMANDS: return f"Command not allowed in read-only mode: {base_cmd}" # Special handling for git - check subcommand if base_cmd == "git": if len(tokens) < 2: return "Git command requires a subcommand" git_subcommand = tokens[1] if git_subcommand not in ALLOWED_GIT_SUBCOMMANDS: return f"Git subcommand not allowed: {git_subcommand}" return None