vibe/vibe/core/tools/builtins/bash.py
Mathias Gesbert add3ab5245 v1.3.4
Co-Authored-By: Quentin Torroba <quentin.torroba@mistral.ai>
Co-Authored-By: Vincent Guilloux <vincent.guilloux@mistral.ai>
Co-Authored-By: Luis Cardoso <luis.cardoso@mistral.ai>
2026-01-08 11:59:28 +01:00

319 lines
9.3 KiB
Python

from __future__ import annotations
import asyncio
from functools import lru_cache
import os
import signal
import sys
from typing import ClassVar, Literal, final
from pydantic import BaseModel, Field
from tree_sitter import Language, Node, Parser
import tree_sitter_bash as tsbash
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
BaseToolState,
ToolError,
ToolPermission,
)
from vibe.core.utils import is_windows
@lru_cache(maxsize=1)
def _get_parser() -> Parser:
return Parser(Language(tsbash.language()))
def _extract_commands(command: str) -> list[str]:
parser = _get_parser()
tree = parser.parse(command.encode("utf-8"))
commands: list[str] = []
def find_commands(node: Node) -> None:
if node.type == "command":
parts = []
for child in node.children:
if (
child.type
in {"command_name", "word", "string", "raw_string", "concatenation"}
and child.text is not None
):
parts.append(child.text.decode("utf-8"))
if parts:
commands.append(" ".join(parts))
for child in node.children:
find_commands(child)
find_commands(tree.root_node)
return commands
def _get_subprocess_encoding() -> str:
if sys.platform == "win32":
# Windows console uses OEM code page (e.g., cp850, cp1252)
import ctypes
return f"cp{ctypes.windll.kernel32.GetOEMCP()}"
return "utf-8"
def _get_base_env() -> dict[str, str]:
base_env = {
**os.environ,
"CI": "true",
"NONINTERACTIVE": "1",
"NO_TTY": "1",
"NO_COLOR": "1",
}
if is_windows():
base_env["GIT_PAGER"] = "more"
base_env["PAGER"] = "more"
else:
base_env["TERM"] = "dumb"
base_env["DEBIAN_FRONTEND"] = "noninteractive"
base_env["GIT_PAGER"] = "cat"
base_env["PAGER"] = "cat"
base_env["LESS"] = "-FX"
base_env["LC_ALL"] = "en_US.UTF-8"
return base_env
async def _kill_process_tree(proc: asyncio.subprocess.Process) -> None:
if proc.returncode is not None:
return
try:
if sys.platform == "win32":
try:
subprocess_proc = await asyncio.create_subprocess_exec(
"taskkill",
"/F",
"/T",
"/PID",
str(proc.pid),
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await subprocess_proc.wait()
except (FileNotFoundError, OSError):
proc.terminate()
else:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
await proc.wait()
except (ProcessLookupError, PermissionError, OSError):
pass
def _get_default_allowlist() -> list[str]:
common = ["echo", "find", "git diff", "git log", "git status", "tree", "whoami"]
if is_windows():
return common + ["dir", "findstr", "more", "type", "ver", "where"]
else:
return common + [
"cat",
"file",
"head",
"ls",
"pwd",
"stat",
"tail",
"uname",
"wc",
"which",
]
def _get_default_denylist() -> list[str]:
common = ["gdb", "pdb", "passwd"]
if is_windows():
return common + ["cmd /k", "powershell -NoExit", "pwsh -NoExit", "notepad"]
else:
return common + [
"nano",
"vim",
"vi",
"emacs",
"bash -i",
"sh -i",
"zsh -i",
"fish -i",
"dash -i",
"screen",
"tmux",
]
def _get_default_denylist_standalone() -> list[str]:
common = ["python", "python3", "ipython"]
if is_windows():
return common + ["cmd", "powershell", "pwsh", "notepad"]
else:
return common + ["bash", "sh", "nohup", "vi", "vim", "emacs", "nano", "su"]
class BashToolConfig(BaseToolConfig):
permission: ToolPermission = ToolPermission.ASK
max_output_bytes: int = Field(
default=16_000, description="Maximum bytes to capture from stdout and stderr."
)
default_timeout: int = Field(
default=30, description="Default timeout for commands in seconds."
)
allowlist: list[str] = Field(
default_factory=_get_default_allowlist,
description="Command prefixes that are automatically allowed",
)
denylist: list[str] = Field(
default_factory=_get_default_denylist,
description="Command prefixes that are automatically denied",
)
denylist_standalone: list[str] = Field(
default_factory=_get_default_denylist_standalone,
description="Commands that are denied only when run without arguments",
)
class BashArgs(BaseModel):
command: str
timeout: int | None = Field(
default=None, description="Override the default command timeout."
)
class BashResult(BaseModel):
stdout: str
stderr: str
returncode: int
class Bash(BaseTool[BashArgs, BashResult, BashToolConfig, BaseToolState]):
description: ClassVar[str] = "Run a one-off bash command and capture its output."
def check_allowlist_denylist(self, args: BashArgs) -> ToolPermission | None:
if is_windows():
return None
command_parts = _extract_commands(args.command)
if not command_parts:
return None
def is_denylisted(command: str) -> bool:
return any(command.startswith(pattern) for pattern in self.config.denylist)
def is_standalone_denylisted(command: str) -> bool:
parts = command.split()
if not parts:
return False
base_command = parts[0]
has_args = len(parts) > 1
if not has_args:
command_name = os.path.basename(base_command)
if command_name in self.config.denylist_standalone:
return True
if base_command in self.config.denylist_standalone:
return True
return False
def is_allowlisted(command: str) -> bool:
return any(command.startswith(pattern) for pattern in self.config.allowlist)
for part in command_parts:
if is_denylisted(part):
return ToolPermission.NEVER
if is_standalone_denylisted(part):
return ToolPermission.NEVER
if all(is_allowlisted(part) for part in command_parts):
return ToolPermission.ALWAYS
return None
@final
def _build_timeout_error(self, command: str, timeout: int) -> ToolError:
return ToolError(f"Command timed out after {timeout}s: {command!r}")
@final
def _build_result(
self, *, command: str, stdout: str, stderr: str, returncode: int
) -> BashResult:
if returncode != 0:
error_msg = f"Command failed: {command!r}\n"
error_msg += f"Return code: {returncode}"
if stderr:
error_msg += f"\nStderr: {stderr}"
if stdout:
error_msg += f"\nStdout: {stdout}"
raise ToolError(error_msg.strip())
return BashResult(stdout=stdout, stderr=stderr, returncode=returncode)
async def run(self, args: BashArgs) -> BashResult:
timeout = args.timeout or self.config.default_timeout
max_bytes = self.config.max_output_bytes
proc = None
try:
# start_new_session is Unix-only, on Windows it's ignored
kwargs: dict[Literal["start_new_session"], bool] = (
{} if is_windows() else {"start_new_session": True}
)
proc = await asyncio.create_subprocess_shell(
args.command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.DEVNULL,
cwd=self.config.effective_workdir,
env=_get_base_env(),
**kwargs,
)
try:
stdout_bytes, stderr_bytes = await asyncio.wait_for(
proc.communicate(), timeout=timeout
)
except TimeoutError:
await _kill_process_tree(proc)
raise self._build_timeout_error(args.command, timeout)
encoding = _get_subprocess_encoding()
stdout = (
stdout_bytes.decode(encoding, errors="replace")[:max_bytes]
if stdout_bytes
else ""
)
stderr = (
stderr_bytes.decode(encoding, errors="replace")[:max_bytes]
if stderr_bytes
else ""
)
returncode = proc.returncode or 0
return self._build_result(
command=args.command,
stdout=stdout,
stderr=stderr,
returncode=returncode,
)
except (ToolError, asyncio.CancelledError):
raise
except Exception as exc:
raise ToolError(f"Error running command {args.command!r}: {exc}") from exc
finally:
if proc is not None:
await _kill_process_tree(proc)