vibe/vibe/core/tools/builtins/bash.py
Mathias Gesbert 9c1c32e058
v2.7.2 (#556)
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
2026-04-01 18:38:22 +02:00

497 lines
16 KiB
Python

from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator
from functools import lru_cache
import os
from pathlib import Path
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.arity import build_session_pattern
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
BaseToolState,
InvokeContext,
ToolError,
ToolPermission,
)
from vibe.core.tools.permissions import (
PermissionContext,
PermissionScope,
RequiredPermission,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.tools.utils import is_path_within_workdir
from vibe.core.types import ToolResultEvent, ToolStreamEvent
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_shell_executable() -> str | None:
if is_windows():
return None
return os.environ.get("SHELL")
def _get_base_env() -> dict[str, str]:
base_env = {**os.environ, "CI": "true", "NONINTERACTIVE": "1", "NO_TTY": "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 = ["cd", "echo", "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"]
_PATH_COMMANDS = {
"cat",
"cd",
"chmod",
"chown",
"cp",
"head",
"ls",
"mkdir",
"mv",
"rm",
"stat",
"tail",
"touch",
"wc",
}
def _collect_outside_dirs(command_parts: list[str]) -> set[str]:
"""Collect parent directories referenced outside the workdir.
Iterates file-manipulating commands (see _PATH_COMMANDS) and inspects
their arguments as candidate paths. Skips flags (-r, --recursive) and
chmod mode strings (+x). For any argument that resolves outside the current
working directory, adds the parent directory (or the path itself when it is
a directory) to the result set — suitable for building an OUTSIDE_DIRECTORY
RequiredPermission.
"""
dirs: set[str] = set()
for part in command_parts:
tokens = part.split()
command = tokens[0] if tokens else None
if not command or command not in _PATH_COMMANDS:
continue
for token in tokens[1:]:
# Skip CLI flags like -r, --recursive
if token.startswith("-"):
continue
# Skip chmod mode strings like +x, +rwx — they are not file paths
if command == "chmod" and token.startswith("+"):
continue
# Only consider tokens that look like paths
if not (
token.startswith(os.sep)
or token.startswith("~")
or token.startswith(".")
or os.sep in token
):
continue
if is_path_within_workdir(token):
continue
# Resolve relative / home-relative paths, then collect parent dir
resolved = Path(token).expanduser()
if not resolved.is_absolute():
resolved = Path.cwd() / resolved
resolved = resolved.resolve()
# For a directory target use the dir itself; for a file use its parent
parent = str(resolved) if resolved.is_dir() else str(resolved.parent)
dirs.add(parent)
return dirs
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=300, 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",
)
sensitive_patterns: list[str] = Field(
default=["sudo"],
description="Command prefixes that always ASK regardless of arity approval.",
)
class BashArgs(BaseModel):
command: str
timeout: int | None = Field(
default=None, description="Override the default command timeout."
)
class BashResult(BaseModel):
command: str
stdout: str
stderr: str
returncode: int
class Bash(
BaseTool[BashArgs, BashResult, BashToolConfig, BaseToolState],
ToolUIData[BashArgs, BashResult],
):
description: ClassVar[str] = "Run a one-off bash command and capture its output."
@classmethod
def format_call_display(cls, args: BashArgs) -> ToolCallDisplay:
return ToolCallDisplay(summary=f"bash: {args.command}")
@classmethod
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
if not isinstance(event.result, BashResult):
return ToolResultDisplay(
success=False, message=event.error or event.skip_reason or "No result"
)
return ToolResultDisplay(success=True, message=f"Ran {event.result.command}")
@classmethod
def get_status_text(cls) -> str:
return "Running command"
def resolve_permission(self, args: BashArgs) -> PermissionContext | None: # noqa: PLR0911, PLR0912
if is_windows():
return None
command_parts = _extract_commands(args.command)
if not command_parts:
return None
def _matches_pattern(command: str, pattern: str) -> bool:
return command == pattern or command.startswith(pattern + " ")
def find_denylist_match(command: str) -> str | None:
return next(
(p for p in self.config.denylist if _matches_pattern(command, p)), None
)
def is_standalone_denylisted(command: str) -> bool:
parts = command.split()
if not parts:
return False
base_command = parts[0]
if len(parts) == 1:
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(
_matches_pattern(command, pattern) for pattern in self.config.allowlist
)
def is_sensitive(command: str) -> bool:
tokens = command.split()
if not tokens:
return False
return tokens[0] in self.config.sensitive_patterns
for part in command_parts:
if matched := find_denylist_match(part):
return PermissionContext(
permission=ToolPermission.NEVER,
reason=f"Command denied: '{part}' matches denylist pattern '{matched}'. Do not attempt to run this command.",
)
if is_standalone_denylisted(part):
return PermissionContext(
permission=ToolPermission.NEVER,
reason=f"Command denied: '{part}' is not allowed as a standalone command. Do not attempt to run this command.",
)
if self.config.permission == ToolPermission.ALWAYS:
return PermissionContext(permission=ToolPermission.ALWAYS)
has_sensitive = any(is_sensitive(part) for part in command_parts)
all_allowlisted = not has_sensitive and all(
is_allowlisted(part) for part in command_parts
)
outside_dirs = _collect_outside_dirs(command_parts)
if all_allowlisted and not outside_dirs:
return PermissionContext(permission=ToolPermission.ALWAYS)
required: list[RequiredPermission] = []
seen_session: set[str] = set()
for part in command_parts:
if not part:
continue
tokens = part.split()
if not tokens:
continue
if not is_sensitive(part) and is_allowlisted(part):
continue
if is_sensitive(part):
required.append(
RequiredPermission(
scope=PermissionScope.COMMAND_PATTERN,
invocation_pattern=part,
session_pattern=part,
label=part,
)
)
else:
session_pat = build_session_pattern(tokens)
if session_pat not in seen_session:
seen_session.add(session_pat)
required.append(
RequiredPermission(
scope=PermissionScope.COMMAND_PATTERN,
invocation_pattern=part,
session_pattern=session_pat,
label=session_pat,
)
)
if outside_dirs:
globs = sorted(str(Path(d) / "*") for d in outside_dirs)
for glob in globs:
required.append(
RequiredPermission(
scope=PermissionScope.OUTSIDE_DIRECTORY,
invocation_pattern=glob,
session_pattern=glob,
label=f"outside workdir ({glob})",
)
)
if not required:
return None
return PermissionContext(
permission=ToolPermission.ASK, required_permissions=required
)
@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(
command=command, stdout=stdout, stderr=stderr, returncode=returncode
)
async def run(
self, args: BashArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | BashResult, None]:
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,
env=_get_base_env(),
executable=_get_shell_executable(),
**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
yield 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)