"""沙箱执行器(WP-06/07 Sandbox):子进程隔离 + 资源限制 + 超时。 隔离边界(按部署档): - 本执行器提供:独立进程、CPU/内存 rlimit、墙钟超时与强杀、输出限额。 - 进程级隔离不是完整安全边界;网络隔离与文件系统最小化在 Linux 容器档 (container 形态)交付。部署者必须显式启用沙箱(HUNJING_SANDBOX_ENABLED), 未启用时一律拒绝执行(fail-closed,AGENTS 第 6 节)。 """ import asyncio import json import resource import sys import time from dataclasses import dataclass from typing import Any # 用户代码经 stdin JSON 传输,不进命令行(避免注入命令行参数) _RUNNER = r''' import json, sys def _main(): payload = json.loads(sys.stdin.read()) scope = {"input": payload.get("input", {})} exec(compile(payload["code"], "", "exec"), scope) result = scope.get("output") sys.stdout.write(json.dumps({"output": result}, ensure_ascii=False)) _main() ''' @dataclass(frozen=True) class SandboxPolicy: max_runtime_ms: int = 5_000 max_memory_mb: int = 128 max_output_chars: int = 10_000 class SandboxUnavailable(Exception): """未启用沙箱:调用方必须显式失败,不得退化为宿主执行。""" def _apply_limits(policy: SandboxPolicy) -> None: # 在子进程内执行(preexec_fn) # RLIMIT_AS 在部分平台(macOS)不可设:尽力而为,失败不阻断进程隔离 cpu_seconds = max(1, policy.max_runtime_ms // 1000 + 1) resource.setrlimit(resource.RLIMIT_CPU, (cpu_seconds, cpu_seconds)) if hasattr(resource, "RLIMIT_AS"): as_bytes = policy.max_memory_mb * 1024 * 1024 try: resource.setrlimit(resource.RLIMIT_AS, (as_bytes, as_bytes)) except (ValueError, OSError): pass async def run_sandboxed( code: str, input_: dict[str, Any], policy: SandboxPolicy | None = None ) -> dict[str, Any]: """在受限子进程中执行插件代码;返回执行结果(不抛业务异常以外的错误)。""" pol = policy or SandboxPolicy() stdin_payload = json.dumps({"code": code, "input": input_}, ensure_ascii=False) started = time.monotonic() try: proc = await asyncio.create_subprocess_exec( sys.executable, "-c", _RUNNER, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, preexec_fn=lambda: _apply_limits(pol), ) except Exception as exc: # 进程无法启动:如实失败 return { "status": "failed", "exit_code": None, "duration_ms": int((time.monotonic() - started) * 1000), "output": None, "error": f"sandbox_spawn_failed: {exc}", } try: stdout, stderr = await asyncio.wait_for( proc.communicate(stdin_payload.encode("utf-8")), timeout=pol.max_runtime_ms / 1000, ) except TimeoutError: proc.kill() await proc.wait() return { "status": "timeout", "exit_code": None, "duration_ms": int((time.monotonic() - started) * 1000), "output": None, "error": f"exceeded {pol.max_runtime_ms}ms wall clock", } duration_ms = int((time.monotonic() - started) * 1000) out_text = stdout.decode("utf-8", "replace")[: pol.max_output_chars] err_text = stderr.decode("utf-8", "replace")[: pol.max_output_chars] if proc.returncode == 0: try: parsed = json.loads(out_text) except json.JSONDecodeError: return { "status": "failed", "exit_code": proc.returncode, "duration_ms": duration_ms, "output": None, "error": "plugin output is not JSON", } return { "status": "succeeded", "exit_code": 0, "duration_ms": duration_ms, "output": parsed.get("output"), "stdout_excerpt": out_text, "error": None, } return { "status": "failed", "exit_code": proc.returncode, "duration_ms": duration_ms, "output": None, "stdout_excerpt": out_text, "error": err_text or f"exit {proc.returncode}", }