Files
webber/webber-api/src/domains/tools/shell/bash_full.py
T
jpmschweitzerandClaude Opus 4.5 d0fa5b38a7 feat: add coding tools (edit_file, write_file, bash)
New tools for code modification:
- EditFileTool: find-and-replace with safety checks (unique match required)
- WriteFileTool: create/overwrite files with path validation
- BashTool: full bash with controlled write access

Security controls on BashTool:
- Allowed: mkdir, touch, cp, mv, rm (single files), git, pip, pytest
- Forbidden: sudo, curl, wget, ssh, rm -rf, chmod 777

Includes 39 new tests (78 total now passing).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 11:16:05 +01:00

431 lines
14 KiB
Python

"""
Full bash command execution tool with controlled write capabilities.
Allows more operations than BashReadOnlyTool but still with safety controls.
"""
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 allowed (includes write operations)
ALLOWED_COMMANDS = {
# File inspection (read-only)
"ls", "find", "cat", "head", "tail", "wc", "file", "stat",
"tree", "du", "df",
# Text processing
"grep", "awk", "sed", "sort", "uniq", "cut", "tr",
# Git (full operations)
"git",
# System info
"pwd", "whoami", "hostname", "uname", "date", "env", "printenv",
"which", "type", "echo",
# Archive operations
"tar", "unzip", "zipinfo",
# Write operations (controlled)
"mkdir", "touch", "cp", "mv", "rm",
"chmod",
# Python ecosystem
"python", "python3", "pip", "pip3", "pytest", "mypy", "ruff",
# Other dev tools
"make", "cargo", "npm", "node", "tsc",
}
# Git subcommands (includes write operations)
ALLOWED_GIT_SUBCOMMANDS = {
# Read-only
"status", "log", "diff", "show", "branch", "tag",
"remote", "config", "ls-files", "ls-tree",
"rev-parse", "describe", "shortlog", "blame",
# Write operations
"add", "commit", "checkout", "switch", "restore",
"merge", "rebase", "cherry-pick",
"stash", "pull", "fetch", "init",
"reset", "clean", "rm", "mv",
}
# Absolutely forbidden - too dangerous regardless of context
ABSOLUTELY_FORBIDDEN = [
# Catastrophic deletes
"rm -rf /",
"rm -rf ~",
"rm -rf .",
"rm -rf *",
# Privilege escalation
"sudo ",
"su ",
"doas ",
# Dangerous permissions
"chmod 777",
"chmod -R 777",
"chown ",
"chgrp ",
# Disk operations
"dd if=",
"mkfs",
"fdisk",
"parted",
# System control
"shutdown",
"reboot",
"poweroff",
"init ",
"systemctl",
# Fork bomb pattern
":(){ :|:& };:",
]
# Network commands are forbidden
NETWORK_FORBIDDEN = [
"curl", "wget", "ssh", "scp", "rsync", "sftp", "ftp",
"nc", "netcat", "telnet", "nmap", "ping",
]
# Dangerous rm flags
DANGEROUS_RM_FLAGS = {"-rf", "-fr", "-r -f", "-f -r", "--recursive --force"}
class BashTool(BaseTool):
"""
Execute bash commands with controlled write capabilities.
More permissive than BashReadOnlyTool but still with safety controls.
"""
name = "bash"
description = """Execute a bash command with write capabilities.
ALLOWED commands:
- File operations: ls, find, cat, head, tail, mkdir, touch, cp, mv
- Git (full): git add, git commit, git checkout, git merge, git pull, etc.
- Python: python, pip install, pytest, mypy, ruff
- Text processing: grep, awk, sed, sort, uniq
- System info: pwd, whoami, date, which
RESTRICTED:
- rm: Single files only, no -rf flag, must be in allowed paths
- mv/cp: Target must be in allowed paths
- chmod: Only safe modes (no 777)
FORBIDDEN (always blocked):
- sudo, su (privilege escalation)
- Network: curl, wget, ssh, scp, rsync
- Dangerous: chmod 777, rm -rf, dd, mkfs, shutdown
Args:
command: The bash command to execute
cwd: Working directory (default: current directory)
timeout: Timeout in seconds (default: 60)
Returns:
Command stdout on success, or error message.
Examples:
- "mkdir -p src/new_module" - Create directory
- "cp template.py src/new_file.py" - Copy file
- "git add . && git commit -m 'feat: add feature'" - Git commit
- "pip install requests" - Install package
- "pytest tests/ -v" - Run tests
- "rm src/old_file.py" - Remove single file
"""
def __init__(
self,
allowed_paths: list[str] | None = None,
default_timeout: int = 60,
max_output_size: int = 50000
):
"""
Initialize BashTool.
Args:
allowed_paths: Allowed working/target 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 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, working_dir)
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, working_dir: Path) -> str | None:
"""
Validate command is safe to execute.
Returns:
Error message if invalid, None if valid
"""
command_lower = command.lower()
# Check absolutely forbidden patterns first
for pattern in ABSOLUTELY_FORBIDDEN:
if pattern.lower() in command_lower:
return f"Command contains forbidden pattern: {pattern}"
# Check network commands
for net_cmd in NETWORK_FORBIDDEN:
# Check as standalone command or with path
if (f" {net_cmd} " in f" {command_lower} " or
command_lower.startswith(f"{net_cmd} ") or
command_lower == net_cmd or
f"/{net_cmd} " in command_lower or
f"/{net_cmd}" == command_lower[-len(net_cmd)-1:]):
return f"Network command not allowed: {net_cmd}"
# Split by command separators to validate each sub-command
# Handle &&, ||, ; but also pipe |
sub_commands = self._split_commands(command)
for sub_cmd in sub_commands:
sub_cmd = sub_cmd.strip()
if not sub_cmd:
continue
error = self._validate_single_command(sub_cmd, working_dir)
if error:
return error
return None
def _split_commands(self, command: str) -> list[str]:
"""Split command by separators (&&, ||, ;) but not pipes."""
# Simple split - could be improved with proper shell parsing
result = []
current = ""
i = 0
while i < len(command):
if command[i:i+2] in ("&&", "||"):
result.append(current)
current = ""
i += 2
elif command[i] == ";":
result.append(current)
current = ""
i += 1
else:
current += command[i]
i += 1
if current:
result.append(current)
return result
def _validate_single_command(self, command: str, working_dir: Path) -> str | None:
"""Validate a single command (no &&, ||, ;)."""
# Handle pipes - validate first command in pipe chain
if "|" in command:
command = command.split("|")[0].strip()
# Parse command
try:
tokens = shlex.split(command)
if not tokens:
return None # Empty is ok (could be whitespace)
except ValueError as e:
return f"Invalid command syntax: {e}"
# Get base command
base_cmd = Path(tokens[0]).name
# Check if command is allowed
if base_cmd not in ALLOWED_COMMANDS:
return f"Command not allowed: {base_cmd}"
# Special handling for specific commands
if base_cmd == "git":
return self._validate_git_command(tokens)
elif base_cmd == "rm":
return self._validate_rm_command(tokens, working_dir)
elif base_cmd in ("cp", "mv"):
return self._validate_copy_move_command(tokens, working_dir)
elif base_cmd == "chmod":
return self._validate_chmod_command(tokens)
elif base_cmd in ("pip", "pip3"):
return self._validate_pip_command(tokens)
return None
def _validate_git_command(self, tokens: list[str]) -> str | None:
"""Validate git command."""
if len(tokens) < 2:
return "Git command requires a subcommand"
git_subcommand = tokens[1]
# Handle git with flags before subcommand (e.g., git -C path status)
if git_subcommand.startswith("-"):
# Find the actual subcommand
for i, token in enumerate(tokens[2:], 2):
if not token.startswith("-"):
git_subcommand = token
break
else:
return "Git command requires a subcommand"
if git_subcommand not in ALLOWED_GIT_SUBCOMMANDS:
return f"Git subcommand not allowed: {git_subcommand}"
# Block git push (could push to remote)
if git_subcommand == "push":
return "git push is not allowed (use manually)"
return None
def _validate_rm_command(self, tokens: list[str], working_dir: Path) -> str | None:
"""Validate rm command - only single files, no -rf."""
# Check for dangerous flags
flags = [t for t in tokens[1:] if t.startswith("-")]
for flag in flags:
if "r" in flag and "f" in flag:
return "rm -rf is not allowed"
if flag in DANGEROUS_RM_FLAGS:
return f"rm flag not allowed: {flag}"
# Must have at least one non-flag argument
args = [t for t in tokens[1:] if not t.startswith("-")]
if not args:
return "rm requires a file argument"
# Validate each path
for arg in args:
path = Path(arg)
if not path.is_absolute():
path = working_dir / path
if not self._validate_path(path, self.allowed_paths):
return f"rm target not in allowed paths: {arg}"
return None
def _validate_copy_move_command(
self, tokens: list[str], working_dir: Path
) -> str | None:
"""Validate cp/mv command - target must be in allowed paths."""
# Get non-flag arguments
args = [t for t in tokens[1:] if not t.startswith("-")]
if len(args) < 2:
return None # Let the command fail naturally
# Last argument is typically the destination
dest = args[-1]
dest_path = Path(dest)
if not dest_path.is_absolute():
dest_path = working_dir / dest_path
if not self._validate_path(dest_path, self.allowed_paths):
return f"Copy/move destination not in allowed paths: {dest}"
return None
def _validate_chmod_command(self, tokens: list[str]) -> str | None:
"""Validate chmod command - block dangerous modes."""
for token in tokens[1:]:
if token.startswith("-"):
continue
# Block 777 and similar
if "777" in token or "666" in token:
return f"chmod mode not allowed: {token}"
return None
def _validate_pip_command(self, tokens: list[str]) -> str | None:
"""Validate pip command - allow install, block uninstall of system packages."""
if len(tokens) < 2:
return None
subcommand = tokens[1]
# Allow help, list, show, freeze, check
allowed_pip = {"install", "list", "show", "freeze", "check", "help", "--help", "-h"}
if subcommand not in allowed_pip:
return f"pip subcommand not allowed: {subcommand}"
return None