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 cyber_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 在 cyber_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"], groups=["system"])
  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. )