code_executor.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. """代码执行服务 - 安全执行用户代码(多语言支持)"""
  2. import subprocess
  3. import tempfile
  4. import os
  5. import re
  6. import platform
  7. from abc import ABC, abstractmethod
  8. from typing import Dict
  9. # ---------------------------------------------------------------------------
  10. # Python 安全检查常量
  11. # ---------------------------------------------------------------------------
  12. BLOCKED_MODULES = {
  13. 'os', 'sys', 'subprocess', 'shutil', 'pathlib',
  14. 'socket', 'http', 'urllib', 'requests',
  15. 'ctypes', 'importlib',
  16. }
  17. BLOCKED_PATTERNS = [
  18. r'\bexec\s*\(',
  19. r'\beval\s*\(',
  20. r'\b__import__\s*\(',
  21. r'\bglobals\s*\(',
  22. r'\blocals\s*\(',
  23. r'\bcompile\s*\(',
  24. ]
  25. # ---------------------------------------------------------------------------
  26. # JavaScript / TypeScript 安全检查常量
  27. # ---------------------------------------------------------------------------
  28. JS_BLOCKED_PATTERNS = [
  29. r"require\s*\(\s*['\"]child_process['\"]\s*\)",
  30. r"require\s*\(\s*['\"]fs['\"]\s*\)",
  31. r"\bprocess\.exit\s*\(",
  32. r"\bprocess\.kill\s*\(",
  33. r"import\s+.*\s+from\s+['\"]fs['\"]",
  34. r"import\s+.*\s+from\s+['\"]child_process['\"]",
  35. ]
  36. # ---------------------------------------------------------------------------
  37. # Bash 安全检查常量
  38. # ---------------------------------------------------------------------------
  39. BASH_SAFE_COMMANDS = {
  40. 'echo', 'ls', 'cat', 'pwd', 'env', 'printf', 'date', 'whoami', 'uname',
  41. }
  42. # Operators: always dangerous via substring match
  43. BASH_BLOCKED_OPERATORS = ['|', '>', '<', '$(', '`', ';', '&&', '||']
  44. # Commands: only dangerous as whole words (avoids false positives like "sh" in "show")
  45. BASH_BLOCKED_COMMANDS = [
  46. r'\bsh\b', r'\bbash\b', r'\bpython\b',
  47. r'\bsudo\b', r'\bchmod\b', r'\brm\b', r'\bmv\b', r'\bcp\b', r'\bdd\b',
  48. r'\bcurl\b', r'\bwget\b', r'\bnc\b',
  49. ]
  50. # ---------------------------------------------------------------------------
  51. # Abstract base
  52. # ---------------------------------------------------------------------------
  53. class BaseExecutor(ABC):
  54. """All language executors inherit from this."""
  55. def __init__(self, timeout: int = 10, max_output: int = 5000):
  56. self.timeout = timeout
  57. self.max_output = max_output
  58. @abstractmethod
  59. def execute(self, code: str) -> Dict:
  60. ...
  61. def _truncate(self, text: str | None) -> str:
  62. if not text:
  63. return ""
  64. if len(text) > self.max_output:
  65. return text[:self.max_output] + "\n... (输出过长,已截断)"
  66. return text
  67. def _timeout_result(self) -> Dict:
  68. return {
  69. "success": False,
  70. "output": "",
  71. "error": f"执行超时(超过{self.timeout}秒)",
  72. "exit_code": -1,
  73. }
  74. def _exception_result(self, exc: Exception) -> Dict:
  75. return {
  76. "success": False,
  77. "output": "",
  78. "error": f"执行失败: {str(exc)}",
  79. "exit_code": -1,
  80. }
  81. def _safety_result(self, msg: str) -> Dict:
  82. return {
  83. "success": False,
  84. "output": "",
  85. "error": f"安全检查失败: {msg}",
  86. "exit_code": -1,
  87. }
  88. def _run_subprocess(self, cmd: list, temp_file: str) -> Dict:
  89. """Run a subprocess, handle timeout / error, clean up temp file."""
  90. try:
  91. result = subprocess.run(
  92. cmd,
  93. capture_output=True,
  94. text=True,
  95. encoding='utf-8',
  96. errors='replace',
  97. timeout=self.timeout,
  98. cwd=tempfile.gettempdir(),
  99. )
  100. output = self._truncate(result.stdout)
  101. error = self._truncate(result.stderr)
  102. return {
  103. "success": result.returncode == 0,
  104. "output": output,
  105. "error": error,
  106. "exit_code": result.returncode,
  107. }
  108. except subprocess.TimeoutExpired:
  109. return self._timeout_result()
  110. except Exception as e:
  111. return self._exception_result(e)
  112. finally:
  113. try:
  114. os.unlink(temp_file)
  115. except OSError:
  116. pass
  117. # ---------------------------------------------------------------------------
  118. # Python executor
  119. # ---------------------------------------------------------------------------
  120. class PythonExecutor(BaseExecutor):
  121. """Executes Python code inside a safety-wrapped temp file."""
  122. def _check_safety(self, code: str) -> str:
  123. for pattern in BLOCKED_PATTERNS:
  124. if re.search(pattern, code):
  125. raise ValueError(f"代码包含不允许的操作: {pattern}")
  126. import_pattern = r'(?:from|import)\s+(\w+)'
  127. imports = re.findall(import_pattern, code)
  128. for module in imports:
  129. if module in BLOCKED_MODULES:
  130. raise ValueError(f"不允许导入模块: {module}")
  131. wrapper = '''
  132. import sys
  133. import io
  134. # 重定向stdout/stderr
  135. _old_stdout = sys.stdout
  136. _old_stderr = sys.stderr
  137. sys.stdout = io.StringIO()
  138. sys.stderr = io.StringIO()
  139. try:
  140. # 用户代码开始
  141. {_code}
  142. # 用户代码结束
  143. finally:
  144. # 恢复stdout/stderr并获取输出
  145. _stdout_output = sys.stdout.getvalue()
  146. _stderr_output = sys.stderr.getvalue()
  147. sys.stdout = _old_stdout
  148. sys.stderr = _old_stderr
  149. # 输出结果
  150. if _stdout_output:
  151. print(_stdout_output, end='')
  152. if _stderr_output:
  153. print(_stderr_output, end='', file=sys.stderr)
  154. '''
  155. indented = '\n'.join(f' {line}' for line in code.split('\n'))
  156. return wrapper.replace('{_code}', indented)
  157. def execute(self, code: str) -> Dict:
  158. try:
  159. safe_code = self._check_safety(code)
  160. except ValueError as e:
  161. return self._safety_result(str(e))
  162. with tempfile.NamedTemporaryFile(
  163. mode='w', suffix='.py', delete=False, encoding='utf-8',
  164. ) as f:
  165. f.write(safe_code)
  166. temp_file = f.name
  167. return self._run_subprocess(['python', temp_file], temp_file)
  168. # ---------------------------------------------------------------------------
  169. # JavaScript executor
  170. # ---------------------------------------------------------------------------
  171. class JavaScriptExecutor(BaseExecutor):
  172. """Executes JavaScript via Node.js."""
  173. def _check_safety(self, code: str) -> None:
  174. for pattern in JS_BLOCKED_PATTERNS:
  175. if re.search(pattern, code):
  176. raise ValueError(f"代码包含不允许的操作: {pattern}")
  177. def execute(self, code: str) -> Dict:
  178. try:
  179. self._check_safety(code)
  180. except ValueError as e:
  181. return self._safety_result(str(e))
  182. with tempfile.NamedTemporaryFile(
  183. mode='w', suffix='.js', delete=False, encoding='utf-8',
  184. ) as f:
  185. f.write(code)
  186. temp_file = f.name
  187. return self._run_subprocess(['node', temp_file], temp_file)
  188. # ---------------------------------------------------------------------------
  189. # TypeScript executor
  190. # ---------------------------------------------------------------------------
  191. class TypeScriptExecutor(BaseExecutor):
  192. """Executes TypeScript via node --experimental-strip-types (Node 22+),
  193. falls back to npx tsx for advanced features (enums, decorators, etc.)."""
  194. def __init__(self):
  195. # npx first-run download can be slow → 30s timeout
  196. super().__init__(timeout=30)
  197. def _check_safety(self, code: str) -> None:
  198. for pattern in JS_BLOCKED_PATTERNS:
  199. if re.search(pattern, code):
  200. raise ValueError(f"代码包含不允许的操作: {pattern}")
  201. def _resolve_npx(self) -> str:
  202. """Return the correct npx command for the current platform."""
  203. return 'npx.cmd' if platform.system() == 'Windows' else 'npx'
  204. def _try_cmd(self, cmd: list, code: str, temp_file: str) -> Dict:
  205. """Run a subprocess, re-creating temp_file (since _run_subprocess cleans it up)."""
  206. # Re-create file (may have been deleted by a previous _run_subprocess)
  207. try:
  208. with open(temp_file, 'w', encoding='utf-8') as f:
  209. f.write(code)
  210. except OSError:
  211. pass
  212. return self._run_subprocess(cmd, temp_file)
  213. def execute(self, code: str) -> Dict:
  214. try:
  215. self._check_safety(code)
  216. except ValueError as e:
  217. return self._safety_result(str(e))
  218. with tempfile.NamedTemporaryFile(
  219. mode='w', suffix='.ts', delete=False, encoding='utf-8',
  220. ) as f:
  221. f.write(code)
  222. temp_file = f.name
  223. # Primary: node --experimental-strip-types (fast, no download needed)
  224. # --no-warnings suppresses ExperimentalWarning from stderr
  225. result = self._try_cmd(['node', '--no-warnings', '--experimental-strip-types', temp_file], code, temp_file)
  226. if result['success']:
  227. return result
  228. # Fallback: npx tsx — handles TS features strip-types doesn't support
  229. npx_cmd = self._resolve_npx()
  230. result = self._try_cmd([npx_cmd, '--yes', 'tsx', temp_file], code, temp_file)
  231. return result
  232. # ---------------------------------------------------------------------------
  233. # Bash executor
  234. # ---------------------------------------------------------------------------
  235. class BashExecutor(BaseExecutor):
  236. """Executes Shell commands.
  237. On Linux/Mac: uses bash.
  238. On Windows: uses sh (Git Bash) if available, falls back to PowerShell.
  239. """
  240. @staticmethod
  241. def _find_shell() -> str | None:
  242. """Locate a usable Unix-compatible shell."""
  243. if platform.system() != 'Windows':
  244. return 'bash'
  245. # On Windows, try 'sh' (Git Bash etc.)
  246. import shutil
  247. sh_path = shutil.which('sh')
  248. if sh_path:
  249. return sh_path
  250. # Check common Git Bash install paths
  251. common_paths = [
  252. r'C:\Program Files\Git\bin\sh.exe',
  253. r'C:\Program Files (x86)\Git\bin\sh.exe',
  254. ]
  255. for p in common_paths:
  256. if os.path.exists(p):
  257. return p
  258. return None
  259. def _check_safety(self, code: str, use_powershell: bool = False) -> None:
  260. if use_powershell:
  261. # PowerShell: block dangerous operators (substring match)
  262. blocked_ops = ['$(', '`', ';']
  263. # PowerShell: block dangerous cmdlets/commands (word-boundary regex)
  264. blocked_cmds = [
  265. r'\brm\b', r'\bRemove-Item\b', r'\bsudo\b', r'\bchmod\b',
  266. r'\bcurl\b', r'\bwget\b', r'\bInvoke-WebRequest\b', r'\biwr\b',
  267. ]
  268. safe_commands = {
  269. 'echo', 'Write-Output', 'Get-ChildItem', 'ls', 'dir',
  270. 'Get-Content', 'cat', 'pwd', 'Get-Location',
  271. 'Get-Date', 'date', 'whoami', 'Get-Command', 'Write-Host',
  272. 'Get-EnvironmentVariable', 'env',
  273. }
  274. else:
  275. blocked_ops = BASH_BLOCKED_OPERATORS
  276. blocked_cmds = BASH_BLOCKED_COMMANDS
  277. safe_commands = BASH_SAFE_COMMANDS
  278. # Check operators (plain substring — dangerous anywhere)
  279. for op in blocked_ops:
  280. if op in code:
  281. raise ValueError(f"代码包含不允许的操作符: {repr(op)}")
  282. # Check blocked commands (word boundary regex — no false positives)
  283. for pattern in blocked_cmds:
  284. if re.search(pattern, code):
  285. raise ValueError(f"代码包含不允许的命令: {pattern}")
  286. # Verify every line starts with an allowed command
  287. for line in code.splitlines():
  288. stripped = line.strip()
  289. if not stripped or stripped.startswith('#'):
  290. continue
  291. first_word = stripped.split()[0]
  292. if first_word not in safe_commands:
  293. raise ValueError(f"不允许的命令: {first_word}")
  294. def execute(self, code: str) -> Dict:
  295. shell = self._find_shell()
  296. use_powershell = shell is None
  297. try:
  298. self._check_safety(code, use_powershell=use_powershell)
  299. except ValueError as e:
  300. return self._safety_result(str(e))
  301. if use_powershell:
  302. # Execute via PowerShell with encoded command
  303. try:
  304. import base64
  305. encoded = base64.b64encode(code.encode('utf-16le')).decode()
  306. result = subprocess.run(
  307. ['powershell.exe', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
  308. capture_output=True, text=True, encoding='utf-8', errors='replace',
  309. timeout=self.timeout,
  310. )
  311. output = self._truncate(result.stdout)
  312. error = self._truncate(result.stderr)
  313. return {
  314. "success": result.returncode == 0,
  315. "output": output,
  316. "error": error,
  317. "exit_code": result.returncode,
  318. }
  319. except subprocess.TimeoutExpired:
  320. return self._timeout_result()
  321. except Exception as e:
  322. return self._exception_result(e)
  323. else:
  324. with tempfile.NamedTemporaryFile(
  325. mode='w', suffix='.sh', delete=False, encoding='utf-8',
  326. ) as f:
  327. f.write(code)
  328. temp_file = f.name
  329. return self._run_subprocess([shell, temp_file], temp_file)
  330. # ---------------------------------------------------------------------------
  331. # Registry
  332. # ---------------------------------------------------------------------------
  333. class CodeExecutorRegistry:
  334. """Routes code to the appropriate language executor."""
  335. def __init__(self):
  336. self._executors = {
  337. "python": PythonExecutor(),
  338. "javascript": JavaScriptExecutor(),
  339. "typescript": TypeScriptExecutor(),
  340. "bash": BashExecutor(),
  341. }
  342. def execute(self, code: str, language: str) -> Dict:
  343. executor = self._executors.get(language)
  344. if not executor:
  345. return {
  346. "success": False,
  347. "output": "",
  348. "error": f"不支持的语言: {language}",
  349. "exit_code": -1,
  350. }
  351. return executor.execute(code)
  352. def supported_languages(self) -> list:
  353. return list(self._executors.keys())
  354. # ---------------------------------------------------------------------------
  355. # Singleton
  356. # ---------------------------------------------------------------------------
  357. _executor = None
  358. def get_executor() -> CodeExecutorRegistry:
  359. """获取代码执行器实例(单例)"""
  360. global _executor
  361. if _executor is None:
  362. _executor = CodeExecutorRegistry()
  363. return _executor