python_sandbox.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. """会话 Python 执行;工作区可写,代码快照与执行凭据由宿主保存。"""
  2. import asyncio
  3. from datetime import datetime
  4. import hashlib
  5. import json
  6. import os
  7. from pathlib import Path
  8. import re
  9. import shutil
  10. import signal
  11. import sys
  12. from zoneinfo import ZoneInfo
  13. import config
  14. from agents.workspace import session_root
  15. def workspace_file(user, session, virtual):
  16. if not isinstance(virtual, str) or not virtual.startswith('/workspace/'):
  17. raise ValueError('文件路径必须以 /workspace/ 开头')
  18. base = session_root(user, session).resolve()
  19. path = (base / virtual.lstrip('/')).resolve()
  20. if not path.is_relative_to(base / 'workspace'):
  21. raise ValueError('路径越出本会话工作区')
  22. return path
  23. def digest(path):
  24. value = hashlib.sha256()
  25. with path.open('rb') as stream:
  26. for chunk in iter(lambda: stream.read(1024 * 1024), b''):
  27. value.update(chunk)
  28. return value.hexdigest()
  29. def capability():
  30. if sys.platform == 'darwin' and shutil.which('sandbox-exec'):
  31. return {'available': True, 'backend': 'macOS sandbox-exec', 'network': False}
  32. if sys.platform.startswith('linux') and shutil.which('bwrap'):
  33. return {'available': True, 'backend': 'Linux bubblewrap', 'network': False}
  34. return {'available': False, 'reason': 'Python 执行需要 macOS sandbox-exec 或 Linux bubblewrap;不会回退为无沙箱执行'}
  35. def _command(workspace, run_dir, script, timeout, memory):
  36. python = Path(sys.executable).absolute()
  37. worker = Path(__file__).with_name('python_worker.py').resolve()
  38. environment = {'PATH': os.defpath, 'HOME': str(workspace), 'TMPDIR': str(workspace / '.tmp'),
  39. 'LANG': 'en_US.UTF-8', 'PYTHONIOENCODING': 'utf-8',
  40. 'OPENBLAS_NUM_THREADS': '1', 'OMP_NUM_THREADS': '1',
  41. 'MPLCONFIGDIR': str(workspace / '.cache/matplotlib')}
  42. if sys.platform == 'darwin' and shutil.which('sandbox-exec'):
  43. readable = {workspace, run_dir, Path(sys.prefix).resolve(), Path(sys.base_prefix).resolve(),
  44. Path('/System/Library'), Path('/usr/lib'), Path('/usr/share'), Path('/Library/Fonts')}
  45. quoted = lambda path: json.dumps(str(path), ensure_ascii=False)
  46. reads = ' '.join('(subpath ' + quoted(path) + ')' for path in sorted(readable))
  47. executables = {python.resolve()}
  48. # python.org 的 macOS Framework 解释器是启动器,会 re-exec 到 Python.app。
  49. framework_executable = Path(sys.base_prefix) / 'Resources/Python.app/Contents/MacOS/Python'
  50. if framework_executable.is_file():
  51. executables.add(framework_executable.resolve())
  52. execution_rules = ' '.join('(literal ' + quoted(path) + ')' for path in sorted(executables))
  53. # dyld 在解释器启动时会打开根目录定位共享缓存;仅放行目录本身,不递归放行宿主文件。
  54. profile = ('(version 1) (deny default) (allow file-read-metadata) '
  55. '(allow file-read-data (literal "/")) '
  56. '(allow sysctl-read) (allow signal (target self)) '
  57. '(allow process-exec ' + execution_rules + ') '
  58. '(allow file-read* ' + reads + ' (literal ' + quoted(worker) + ') '
  59. '(literal "/dev/null") (literal "/dev/urandom") (literal "/dev/random")) '
  60. '(allow file-map-executable ' + reads + ') '
  61. '(allow file-write* (subpath ' + quoted(workspace) + ') (literal "/dev/null")) '
  62. '(allow mach-lookup (global-name "com.apple.system.logger"))')
  63. # worker 本身不含配置、凭证;不向子进程传入服务端环境变量。
  64. return [shutil.which('sandbox-exec'), '-p', profile, str(python), '-I', '-B', '-u',
  65. str(worker), str(script), str(timeout), str(memory)], environment
  66. if sys.platform.startswith('linux') and shutil.which('bwrap'):
  67. command = [shutil.which('bwrap'), '--die-with-parent', '--new-session', '--unshare-all',
  68. '--cap-drop', 'ALL', '--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp']
  69. roots = {Path('/usr'), Path('/lib'), Path('/lib64'), Path(sys.prefix).resolve(), Path(sys.base_prefix).resolve()}
  70. for path in sorted(roots):
  71. if path.exists():
  72. command.extend(['--ro-bind', str(path), str(path)])
  73. command.extend(['--bind', str(workspace), '/workspace', '--ro-bind', str(run_dir), '/run-record',
  74. '--ro-bind', str(worker), '/python_worker.py', '--chdir', '/workspace'])
  75. environment.update(HOME='/workspace', TMPDIR='/workspace/.tmp', MPLCONFIGDIR='/workspace/.cache/matplotlib')
  76. command.extend([str(python), '-I', '-B', '-u', '/python_worker.py',
  77. '/run-record/' + script.name, str(timeout), str(memory)])
  78. return command, environment
  79. raise ValueError(capability()['reason'])
  80. async def run(user, session, script_path, arguments=(), output_paths=(), timeout_seconds=60):
  81. script = workspace_file(user, session, script_path)
  82. if not script.is_file() or script.suffix != '.py' or script.stat().st_size > 1024 * 1024:
  83. raise ValueError('请选择工作区内不超过 1MB 的 Python 脚本')
  84. if len(arguments) > 32 or any(not isinstance(item, str) or len(item) > 4096 for item in arguments):
  85. raise ValueError('脚本参数数量或长度超过上限')
  86. if len(output_paths) > 20:
  87. raise ValueError('最多声明 20 个输出文件')
  88. outputs = [(virtual, workspace_file(user, session, virtual)) for virtual in output_paths]
  89. def output_version(path):
  90. if not path.is_file():
  91. return None
  92. state = path.stat()
  93. return state.st_ino, state.st_size, state.st_mtime_ns, state.st_ctime_ns
  94. prior_outputs = {virtual: output_version(path) for virtual, path in outputs}
  95. timeout = max(1, min(int(timeout_seconds), config.get_int('AGENT_PYTHON_MAX_SECONDS', 120)))
  96. root = session_root(user, session).resolve()
  97. workspace = root / 'workspace'
  98. workspace.mkdir(exist_ok=True)
  99. runs = root / 'python_runs'
  100. if runs.exists() and sum(1 for item in runs.iterdir() if item.is_dir()) >= config.get_int('AGENT_PYTHON_MAX_RUNS', 40):
  101. raise ValueError('本会话 Python 执行次数已达上限,请根据已有证据提交结果')
  102. (workspace / '.tmp').mkdir(exist_ok=True)
  103. identifier = os.urandom(16).hex()
  104. record_dir = root / 'python_runs' / identifier
  105. record_dir.mkdir(parents=True)
  106. snapshot = record_dir / 'script.py'
  107. snapshot.write_bytes(script.read_bytes())
  108. command, environment = _command(workspace, record_dir, snapshot, timeout,
  109. config.get_int('AGENT_PYTHON_MEMORY_MB', 2048))
  110. command.extend(arguments)
  111. limit = 64 * 1024
  112. async def consume(stream):
  113. buffer = bytearray()
  114. truncated = False
  115. while chunk := await stream.read(8192):
  116. remaining = max(0, limit - len(buffer))
  117. buffer.extend(chunk[:remaining])
  118. truncated |= len(chunk) > remaining
  119. return buffer.decode('utf-8', errors='replace'), truncated
  120. process = await asyncio.create_subprocess_exec(*command, cwd=workspace, env=environment,
  121. stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, start_new_session=True)
  122. readers = [asyncio.create_task(consume(process.stdout)), asyncio.create_task(consume(process.stderr))]
  123. timed_out = False
  124. try:
  125. await asyncio.wait_for(process.wait(), timeout=timeout)
  126. except (TimeoutError, asyncio.CancelledError) as exc:
  127. try:
  128. os.killpg(process.pid, signal.SIGKILL)
  129. except ProcessLookupError:
  130. pass
  131. await process.wait()
  132. if isinstance(exc, asyncio.CancelledError):
  133. await asyncio.gather(*readers, return_exceptions=True)
  134. raise
  135. timed_out = True
  136. finally:
  137. # 终止脚本留下的子进程,防止管道不关闭或任务结束后继续运行。
  138. try:
  139. os.killpg(process.pid, signal.SIGKILL)
  140. except ProcessLookupError:
  141. pass
  142. captured = await asyncio.gather(*readers)
  143. receipt = {'ok': process.returncode == 0 and not timed_out,
  144. 'run_id': identifier, 'script_path': script_path, 'script_sha256': digest(snapshot),
  145. 'exit_code': process.returncode, 'timed_out': timed_out,
  146. 'stdout': captured[0][0], 'stderr': captured[1][0],
  147. 'output_truncated': any(item[1] for item in captured), 'outputs': [],
  148. 'declared_outputs': list(output_paths), 'unchanged_outputs': [], 'missing_outputs': [],
  149. 'backend': capability().get('backend'), 'network': False,
  150. 'created_at': datetime.now(ZoneInfo('Asia/Shanghai')).isoformat()}
  151. for virtual, path in outputs:
  152. # 脚本可能替换路径或建立符号链接,执行后重新解析边界。
  153. checked = workspace_file(user, session, virtual)
  154. if checked.is_file():
  155. if output_version(checked) == prior_outputs[virtual]:
  156. receipt['unchanged_outputs'].append(virtual)
  157. else:
  158. receipt['outputs'].append({'path': virtual, 'sha256': digest(checked), 'size': checked.stat().st_size})
  159. else:
  160. receipt['missing_outputs'].append(virtual)
  161. receipt['receipt_path'] = '/workspace/executions/' + identifier + '.json'
  162. encoded = json.dumps(receipt, ensure_ascii=False, indent=2)
  163. (record_dir / 'receipt.json').write_text(encoded, encoding='utf-8')
  164. readable = workspace_file(user, session, receipt['receipt_path'])
  165. readable.parent.mkdir(parents=True, exist_ok=True)
  166. readable.write_text(encoded, encoding='utf-8')
  167. return receipt
  168. def execution_receipt(user, session, identifier):
  169. if not isinstance(identifier, str) or not re.fullmatch(r'[0-9a-f]{32}', identifier):
  170. raise ValueError('无效 Python 执行标识')
  171. path = session_root(user, session) / 'python_runs' / identifier / 'receipt.json'
  172. if not path.is_file():
  173. raise ValueError('Python 执行记录不存在或不属于当前会话')
  174. value = json.loads(path.read_text())
  175. if value['exit_code'] != 0 or value['timed_out']:
  176. raise ValueError('不能使用失败或超时的 Python 执行作为完成凭据')
  177. for output in value['outputs']:
  178. path = workspace_file(user, session, output['path'])
  179. if not path.is_file() or digest(path) != output['sha256']:
  180. raise ValueError('Python 输出文件已变化,请重新执行相应脚本')
  181. return value