Files
webber/webber-api/src/domains/tools/shell/bash.py
T
jpmschweitzerandClaude Opus 4.5 3b58fa4f8b
Build and Push API / release (push) Successful in 3s
Build and Push API / build (push) Successful in 2m27s
refactor: reorganize into monorepo with separate subprojects
Structure webber into three independent subprojects:
- webber-api/: FastAPI backend server with all agent code
- webber-cli/: Standalone CLI client (renamed from cli/ to webber_cli/)
- webber-sandbox/: Test project for functional testing

Key changes:
- Each subproject has its own .venv (Python 3.12+)
- Added sandbox.sh for managing test project templates
- Created sandbox-templates/ with calculator-cli and empty starter
- Updated CI/CD for prefixed tags (api/v*, cli/v*)
- Added comprehensive AGENTS.md with operational instructions
- Added gitignore filtering to glob and grep tools
- Created pyproject.toml for each subproject

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

237 lines
7.1 KiB
Python

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