bash.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. """
  2. Bash Tool - 命令执行工具
  3. 核心功能:
  4. - 执行 shell 命令
  5. - 超时控制
  6. - 工作目录设置
  7. - 环境变量传递
  8. - 虚拟环境隔离(Python 命令,强制执行,LLM 不可控)
  9. - 目录白名单保护
  10. """
  11. import os
  12. import signal
  13. import asyncio
  14. import logging
  15. from pathlib import Path
  16. from typing import Optional, Dict
  17. from agent.tools import tool, ToolResult, ToolContext
  18. # 常量
  19. DEFAULT_TIMEOUT = 120
  20. MAX_OUTPUT_LENGTH = 50000
  21. GRACEFUL_KILL_WAIT = 3
  22. # ===== 安全配置(模块级,LLM 不可控)=====
  23. ENABLE_VENV = True # 是否强制启用虚拟环境
  24. VENV_DIR = ".venv" # 虚拟环境目录名(相对于项目根目录)
  25. ALLOWED_WORKDIR_PATTERNS = [
  26. ".",
  27. "examples",
  28. "examples/**",
  29. ".cache",
  30. ".cache/**",
  31. "tests",
  32. "tests/**",
  33. "output",
  34. "output/**",
  35. ]
  36. PYTHON_KEYWORDS = ["python", "python3", "pip", "pip3", "pytest", "poetry", "uv"]
  37. logger = logging.getLogger(__name__)
  38. def _get_project_root() -> Path:
  39. """获取项目根目录(bash.py 在 agent/tools/builtin/ 下)"""
  40. return Path(__file__).parent.parent.parent.parent
  41. def _is_safe_workdir(path: Path) -> bool:
  42. """检查工作目录是否在白名单内"""
  43. try:
  44. project_root = _get_project_root()
  45. resolved_path = path.resolve()
  46. resolved_root = project_root.resolve()
  47. if not resolved_path.is_relative_to(resolved_root):
  48. return False
  49. relative_path = resolved_path.relative_to(resolved_root)
  50. for pattern in ALLOWED_WORKDIR_PATTERNS:
  51. if pattern == ".":
  52. if relative_path == Path("."):
  53. return True
  54. elif pattern.endswith("/**"):
  55. base = Path(pattern[:-3])
  56. if relative_path == base or relative_path.is_relative_to(base):
  57. return True
  58. else:
  59. if relative_path == Path(pattern):
  60. return True
  61. return False
  62. except (ValueError, OSError) as e:
  63. logger.warning(f"路径检查失败: {e}")
  64. return False
  65. def _should_use_venv(command: str) -> bool:
  66. """判断命令是否应该在虚拟环境中执行"""
  67. command_lower = command.lower()
  68. for keyword in PYTHON_KEYWORDS:
  69. if keyword in command_lower:
  70. return True
  71. return False
  72. async def _ensure_venv(venv_path: Path) -> bool:
  73. """确保虚拟环境存在,不存在则创建"""
  74. if os.name == 'nt':
  75. python_exe = venv_path / "Scripts" / "python.exe"
  76. else:
  77. python_exe = venv_path / "bin" / "python"
  78. if venv_path.exists() and python_exe.exists():
  79. return True
  80. # 创建虚拟环境
  81. print(f"[bash] 正在创建虚拟环境: {venv_path}")
  82. logger.info(f"创建虚拟环境: {venv_path}")
  83. try:
  84. process = await asyncio.create_subprocess_shell(
  85. f"python -m venv {venv_path}",
  86. stdout=asyncio.subprocess.PIPE,
  87. stderr=asyncio.subprocess.PIPE,
  88. )
  89. stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=60)
  90. if process.returncode == 0:
  91. print(f"[bash] ✅ 虚拟环境创建成功: {venv_path}")
  92. logger.info(f"虚拟环境创建成功: {venv_path}")
  93. return True
  94. else:
  95. err_text = stderr.decode('utf-8', errors='replace') if stderr else ""
  96. print(f"[bash] ❌ 虚拟环境创建失败: {err_text[:200]}")
  97. logger.error(f"虚拟环境创建失败: exit code {process.returncode}, {err_text[:200]}")
  98. return False
  99. except Exception as e:
  100. print(f"[bash] ❌ 虚拟环境创建异常: {e}")
  101. logger.error(f"虚拟环境创建异常: {e}")
  102. return False
  103. def _wrap_command_with_venv(command: str, venv_path: Path) -> str:
  104. """将命令包装为在虚拟环境中执行"""
  105. if os.name == 'nt':
  106. activate_script = venv_path / "Scripts" / "activate.bat"
  107. return f'call "{activate_script}" && {command}'
  108. else:
  109. activate_script = venv_path / "bin" / "activate"
  110. return f'source "{activate_script}" && {command}'
  111. def _kill_process_tree(pid: int) -> None:
  112. """先 SIGTERM 整个进程组,等 GRACEFUL_KILL_WAIT 秒后 SIGKILL 兜底。"""
  113. import time
  114. try:
  115. pgid = os.getpgid(pid)
  116. except ProcessLookupError:
  117. return
  118. try:
  119. os.killpg(pgid, signal.SIGTERM)
  120. except ProcessLookupError:
  121. return
  122. time.sleep(GRACEFUL_KILL_WAIT)
  123. try:
  124. os.killpg(pgid, signal.SIGKILL)
  125. except ProcessLookupError:
  126. pass
  127. @tool(description="执行 bash 命令", hidden_params=["context"])
  128. async def bash_command(
  129. command: str,
  130. timeout: Optional[int] = None,
  131. workdir: Optional[str] = None,
  132. env: Optional[Dict[str, str]] = None,
  133. description: str = "",
  134. context: Optional[ToolContext] = None
  135. ) -> ToolResult:
  136. """
  137. 执行 bash 命令
  138. Args:
  139. command: 要执行的命令
  140. timeout: 超时时间(秒),默认 120 秒
  141. workdir: 工作目录,默认为当前目录
  142. env: 环境变量字典(会合并到系统环境变量)
  143. description: 命令描述(5-10 个词)
  144. context: 工具上下文
  145. Returns:
  146. ToolResult: 命令输出
  147. """
  148. if timeout is not None and timeout < 0:
  149. return ToolResult(
  150. title="参数错误",
  151. output=f"无效的 timeout 值: {timeout}。必须是正数。",
  152. error="Invalid timeout"
  153. )
  154. timeout_sec = timeout or DEFAULT_TIMEOUT
  155. # 工作目录
  156. cwd = Path(workdir) if workdir else Path.cwd()
  157. if not cwd.exists():
  158. return ToolResult(
  159. title="目录不存在",
  160. output=f"工作目录不存在: {workdir}",
  161. error="Directory not found"
  162. )
  163. # 目录白名单检查
  164. if not _is_safe_workdir(cwd):
  165. project_root = _get_project_root()
  166. return ToolResult(
  167. title="目录不允许",
  168. output=(
  169. f"工作目录不在白名单内: {cwd}\n"
  170. f"项目根目录: {project_root}\n"
  171. f"允许的目录: {', '.join(ALLOWED_WORKDIR_PATTERNS)}"
  172. ),
  173. error="Directory not allowed"
  174. )
  175. # 虚拟环境处理(强制执行,LLM 不可绕过)
  176. actual_command = command
  177. if ENABLE_VENV and _should_use_venv(command):
  178. venv_dir = _get_project_root() / VENV_DIR
  179. venv_ok = await _ensure_venv(venv_dir)
  180. if venv_ok:
  181. actual_command = _wrap_command_with_venv(command, venv_dir)
  182. print(f"[bash] 🐍 使用虚拟环境: {venv_dir}")
  183. logger.info(f"[bash] 使用虚拟环境: {venv_dir}")
  184. else:
  185. logger.warning(f"[bash] 虚拟环境不可用,回退到系统环境: {venv_dir}")
  186. # 准备环境变量
  187. process_env = os.environ.copy()
  188. if env:
  189. process_env.update(env)
  190. # 执行命令
  191. try:
  192. if os.name == 'nt':
  193. process = await asyncio.create_subprocess_shell(
  194. actual_command,
  195. stdout=asyncio.subprocess.PIPE,
  196. stderr=asyncio.subprocess.PIPE,
  197. cwd=str(cwd),
  198. env=process_env,
  199. )
  200. else:
  201. process = await asyncio.create_subprocess_shell(
  202. actual_command,
  203. stdout=asyncio.subprocess.PIPE,
  204. stderr=asyncio.subprocess.PIPE,
  205. cwd=str(cwd),
  206. env=process_env,
  207. start_new_session=True,
  208. )
  209. try:
  210. stdout, stderr = await asyncio.wait_for(
  211. process.communicate(),
  212. timeout=timeout_sec
  213. )
  214. except asyncio.TimeoutError:
  215. _kill_process_tree(process.pid)
  216. try:
  217. await asyncio.wait_for(process.wait(), timeout=GRACEFUL_KILL_WAIT + 2)
  218. except asyncio.TimeoutError:
  219. pass
  220. return ToolResult(
  221. title="命令超时",
  222. output=f"命令执行超时(>{timeout_sec}s): {command[:100]}",
  223. error="Timeout",
  224. metadata={"command": command, "timeout": timeout_sec}
  225. )
  226. stdout_text = stdout.decode('utf-8', errors='replace') if stdout else ""
  227. stderr_text = stderr.decode('utf-8', errors='replace') if stderr else ""
  228. truncated = False
  229. if len(stdout_text) > MAX_OUTPUT_LENGTH:
  230. stdout_text = stdout_text[:MAX_OUTPUT_LENGTH] + f"\n\n(输出被截断,总长度: {len(stdout_text)} 字符)"
  231. truncated = True
  232. output = ""
  233. if stdout_text:
  234. output += stdout_text
  235. if stderr_text:
  236. if output:
  237. output += "\n\n--- stderr ---\n"
  238. output += stderr_text
  239. if not output:
  240. output = "(命令无输出)"
  241. exit_code = process.returncode
  242. success = exit_code == 0
  243. title = description or f"命令: {command[:50]}"
  244. if not success:
  245. title += f" (exit code: {exit_code})"
  246. return ToolResult(
  247. title=title,
  248. output=output,
  249. metadata={
  250. "exit_code": exit_code,
  251. "success": success,
  252. "truncated": truncated,
  253. "command": command,
  254. "cwd": str(cwd)
  255. },
  256. error=None if success else f"Command failed with exit code {exit_code}"
  257. )
  258. except Exception as e:
  259. return ToolResult(
  260. title="执行错误",
  261. output=f"命令执行失败: {str(e)}",
  262. error=str(e),
  263. metadata={"command": command}
  264. )