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>
237 lines
7.1 KiB
Python
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
|