""" tools/bash_tool.py — Sandboxed bash command execution. Runs shell commands in a working directory that must be within the filesystem whitelist. Captures stdout, stderr, and exit code. Requires confirmation in interactive sessions. For scheduled tasks and agents, declare "bash" in allowed_tools to enable it without confirmation. """ from __future__ import annotations import asyncio from ..context_vars import current_user as _current_user_var from ..security import SecurityError, assert_path_allowed from .base import BaseTool, ToolResult DEFAULT_TIMEOUT = 30 # seconds MAX_TIMEOUT = 120 # seconds MAX_OUTPUT_BYTES = 50_000 # 50 KB per stream class BashTool(BaseTool): name = "bash" description = ( "Execute a shell command in a sandboxed working directory. " "working_directory must be within the filesystem whitelist. " "Returns stdout, stderr, and exit_code. " "Use for running scripts, CLI tools, and automation tasks." ) input_schema = { "type": "object", "properties": { "command": { "type": "string", "description": "Shell command to execute (run via /bin/bash -c)", }, "working_directory": { "type": "string", "description": ( "Absolute path to run the command in. " "Must be within the filesystem whitelist." ), }, "timeout": { "type": "integer", "description": ( f"Timeout in seconds (default {DEFAULT_TIMEOUT}, max {MAX_TIMEOUT}). " "The command is killed if it exceeds this limit." ), }, }, "required": ["command", "working_directory"], } requires_confirmation = True allowed_in_scheduled_tasks = True async def execute( self, command: str, working_directory: str, timeout: int = DEFAULT_TIMEOUT, **kwargs, ) -> ToolResult: # Defence-in-depth: reject non-admin users regardless of how the tool was invoked _user = _current_user_var.get() if _user is not None and not _user.is_admin: return ToolResult(success=False, error="Bash tool requires administrator privileges.") # Validate working directory against filesystem whitelist try: safe_cwd = await assert_path_allowed(working_directory) except SecurityError as e: return ToolResult(success=False, error=str(e)) if not safe_cwd.is_dir(): return ToolResult( success=False, error=f"Working directory does not exist: {working_directory}", ) timeout = max(1, min(int(timeout), MAX_TIMEOUT)) try: proc = await asyncio.create_subprocess_shell( command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=str(safe_cwd), ) try: stdout_bytes, stderr_bytes = await asyncio.wait_for( proc.communicate(), timeout=timeout ) except asyncio.TimeoutError: proc.kill() await proc.communicate() return ToolResult( success=False, error=f"Command timed out after {timeout}s", ) stdout = stdout_bytes[:MAX_OUTPUT_BYTES].decode("utf-8", errors="replace") stderr = stderr_bytes[:MAX_OUTPUT_BYTES].decode("utf-8", errors="replace") exit_code = proc.returncode return ToolResult( success=exit_code == 0, data={"stdout": stdout, "stderr": stderr, "exit_code": exit_code}, error=f"Exit code {exit_code}: {(stderr or stdout)[:500]}" if exit_code != 0 else None, ) except Exception as e: return ToolResult(success=False, error=f"Failed to run command: {e}") def confirmation_description( self, command: str = "", working_directory: str = "", **kwargs ) -> str: return f"Run shell command in {working_directory}:\n{command}"