Files
xiaoli-cli/xiaoli/sandbox.py
T
Xiaoli Dev ff53a89754 feat: 重写沙箱 + 完整测试套件 (192 tests)
沙箱 v2 - 多层隔离:
- AST 静态分析: 30+ 危险模式检测
- 子进程隔离: 独立进程执行 + 资源限制
- 网络阻断: socket 模块替换
- 模块白名单: 30 个安全模块
- 文件系统限制: 临时目录隔离
- SandboxResult 数据类 + 执行历史

测试套件 (8 个模块, 192 个用例):
- test_sandbox.py:     AST检查(47) + 沙箱执行(15) + 安全拒绝(20) + 超时(2) + 模式对比(3)
- test_conversation.py: 响应解析(13) + 思考标记(5) + 工具提取(10)
- test_config.py:       配置加载/保存/缓存/损坏处理(8)
- test_history.py:      历史CRUD/持久化/边界(12)
- test_engines.py:      引擎管理/加载/切换(14)
- test_plugins.py:      code_editor(10) + code_search(7) + git_tools(4) + tool_manager(7)
- test_integration.py:  沙箱+对话(2) + 历史+对话(2) + 安全链(3) + 提示词(1)
2026-04-25 11:43:47 +08:00

541 lines
19 KiB
Python

"""
安全代码沙箱 v2 - 多层隔离执行环境
安全层级:
1. AST 静态分析 - 拒绝危险语法
2. 子进程隔离 - 独立进程执行,资源限制
3. 文件系统限制 - 仅允许临时目录读写
4. 网络阻断 - 禁止 socket 连接
5. 模块白名单 - 仅允许安全模块导入
6. 输出截断 - 防止内存耗尽
"""
import ast
import io
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import textwrap
import time
import traceback
import threading
from contextlib import redirect_stdout, redirect_stderr
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
import logging
logger = logging.getLogger(__name__)
try:
import resource
HAS_RESOURCE = True
except ImportError:
HAS_RESOURCE = False
# ──────────────────────────────────────────────
# 安全策略配置
# ──────────────────────────────────────────────
BLOCKED_MODULES: Set[str] = frozenset({
'os', 'sys', 'subprocess', 'shutil', 'socket', 'pickle',
'marshal', 'ctypes', 'multiprocessing', 'threading',
'signal', 'resource', 'posix', 'nt', 'builtins',
'importlib', 'code', 'codeop', 'bdb', 'pdb',
'compileall', 'py_compile', 'zipimport', 'pkgutil',
'http', 'urllib', 'ftplib', 'smtplib', 'xmlrpc',
'asyncio', 'concurrent', '_thread',
})
BLOCKED_NAMES: Set[str] = frozenset({
'exec', 'eval', 'compile', 'execfile', 'open',
'globals', 'locals', 'vars', 'getattr', 'setattr',
'delattr', '__import__', '__builtins__', '__subclasses__',
'__loader__', '__spec__', '__file__', '__name__',
'breakpoint', 'exit', 'quit', 'help',
})
BLOCKED_AST_PATTERNS: List[str] = [
r'__', r'\beval\b', r'\bexec\b', r'\bcompile\b',
r'\bglobals\b', r'\blocals\b', r'\bgetattr\b',
r'\bsetattr\b', r'\b__import__\b', r'\bbreakpoint\b',
]
DEFAULT_WHITELIST: List[str] = [
'json', 'math', 'datetime', 'random', 'collections',
'itertools', 'functools', 'operator', 'typing', 're',
'string', 'textwrap', 'copy', 'decimal', 'fractions',
'statistics', 'bisect', 'heapq', 'array', 'struct',
'hashlib', 'hmac', 'secrets', 'base64', 'binascii',
'uuid', 'pathlib', 'enum', 'dataclasses', 'contextlib',
]
DEFAULT_MEMORY_LIMIT_MB = 256
DEFAULT_CPU_TIME_LIMIT = 30
DEFAULT_OUTPUT_LIMIT = 1_000_000
# ──────────────────────────────────────────────
# 结果数据类
# ──────────────────────────────────────────────
@dataclass
class SandboxResult:
"""沙箱执行结果"""
success: bool
result: str = ""
stdout: str = ""
stderr: str = ""
time: float = 0.0
exit_code: int = 0
killed: bool = False
security_violation: str = ""
def to_dict(self) -> dict:
return {
'success': self.success,
'result': self.result,
'stdout': self.stdout,
'stderr': self.stderr,
'time': round(self.time, 4),
'exit_code': self.exit_code,
'killed': self.killed,
'security_violation': self.security_violation,
}
def __str__(self) -> str:
status = "OK" if self.success else "FAIL"
parts = [f"{status}: {self.result}"]
if self.stdout:
parts.append(f"stdout={self.stdout[:200]}")
if self.stderr:
parts.append(f"stderr={self.stderr[:200]}")
if self.security_violation:
parts.append(f"security={self.security_violation}")
parts.append(f"{self.time:.3f}s")
return " | ".join(parts)
# ──────────────────────────────────────────────
# AST 安全检查器
# ──────────────────────────────────────────────
class ASTSecurityChecker(ast.NodeVisitor):
"""AST 级静态安全分析"""
def __init__(self, blocked_modules: Set[str] = None,
blocked_names: Set[str] = None):
self.blocked = blocked_modules or BLOCKED_MODULES
self.blocked_names = blocked_names or BLOCKED_NAMES
self.violations: List[str] = []
def _check_name(self, name: str, node: ast.AST):
lineno = getattr(node, 'lineno', 0)
if name in self.blocked_names:
self.violations.append(f"blocked name: {name} (line {lineno})")
if name.split('.')[0] in self.blocked:
self.violations.append(f"blocked module: {name} (line {lineno})")
def visit_Import(self, node):
for alias in node.names:
self._check_name(alias.name, node)
self.generic_visit(node)
def visit_ImportFrom(self, node):
if node.module:
self._check_name(node.module, node)
self.generic_visit(node)
def visit_Call(self, node):
if isinstance(node.func, ast.Name):
self._check_name(node.func.id, node)
elif isinstance(node.func, ast.Attribute):
if isinstance(node.func.value, ast.Name):
name = node.func.value.id
if name in self.blocked_names:
lineno = getattr(node, 'lineno', 0)
self.violations.append(
f"blocked call: {name}.{node.func.attr} (line {lineno})"
)
self.generic_visit(node)
def visit_Name(self, node):
self._check_name(node.id, node)
self.generic_visit(node)
def visit_Attribute(self, node):
if node.attr.startswith('__') and node.attr.endswith('__'):
if node.attr not in ('__init__', '__str__', '__repr__',
'__len__', '__contains__', '__iter__',
'__eq__', '__ne__', '__lt__', '__gt__',
'__le__', '__ge__', '__hash__', '__bool__',
'__add__', '__sub__', '__mul__', '__truediv__',
'__floordiv__', '__mod__', '__pow__',
'__enter__', '__exit__'):
lineno = getattr(node, 'lineno', 0)
self.violations.append(
f"blocked dunder: {node.attr} (line {lineno})"
)
self.generic_visit(node)
def visit_Delete(self, node):
self.violations.append(f"blocked del (line {getattr(node, 'lineno', 0)})")
self.generic_visit(node)
def visit_Global(self, node):
self.violations.append(f"blocked global (line {getattr(node, 'lineno', 0)})")
self.generic_visit(node)
def visit_Nonlocal(self, node):
self.violations.append(f"blocked nonlocal (line {getattr(node, 'lineno', 0)})")
self.generic_visit(node)
def check(self, code: str) -> Tuple[bool, List[str]]:
"""检查代码安全性,返回 (safe, violations)"""
self.violations = []
for pattern in BLOCKED_AST_PATTERNS:
if re.search(pattern, code, re.IGNORECASE):
self.violations.append(f"regex match: {pattern}")
try:
tree = ast.parse(code)
except SyntaxError as e:
return False, [f"syntax error: {e}"]
self.visit(tree)
return len(self.violations) == 0, self.violations
# ──────────────────────────────────────────────
# 子进程包装脚本
# ──────────────────────────────────────────────
def _make_worker_script(code: str, variables_json: str,
whitelist_json: str, work_dir: str) -> str:
return textwrap.dedent(f'''\
import sys
import os
import json
import io
import traceback
from contextlib import redirect_stdout, redirect_stderr
os.chdir({work_dir!r})
# Block networking
class _FakeSocket:
def __getattr__(self, name):
raise OSError("network access blocked by sandbox")
class _FakeSocketModule:
AF_INET = 0
SOCK_STREAM = 0
def __getattr__(self, name):
raise OSError("network access blocked by sandbox")
def socket(self, *a, **kw):
raise OSError("network access blocked by sandbox")
import types
_fake_mod = _FakeSocketModule()
sys.modules['socket'] = _fake_mod
sys.modules['_socket'] = _fake_mod
WHITELIST = json.loads({whitelist_json!r})
safe_modules = {{}}
for mod_name in WHITELIST:
try:
safe_modules[mod_name] = __import__(mod_name)
except ImportError:
pass
safe_builtins = {{
'print': print, 'len': len, 'str': str, 'int': int,
'float': float, 'list': list, 'dict': dict, 'tuple': tuple,
'set': set, 'frozenset': frozenset,
'range': range, 'enumerate': enumerate, 'zip': zip,
'sorted': sorted, 'reversed': reversed,
'sum': sum, 'max': max, 'min': min, 'abs': abs,
'round': round, 'pow': pow, 'divmod': divmod,
'type': type, 'isinstance': isinstance, 'issubclass': issubclass,
'hasattr': hasattr, 'callable': callable, 'id': id,
'chr': chr, 'ord': ord, 'hex': hex, 'oct': oct, 'bin': bin,
'bool': bool, 'bytes': bytes, 'bytearray': bytearray,
'map': map, 'filter': filter,
'any': any, 'all': all,
'True': True, 'False': False, 'None': None,
}}
user_vars = json.loads({variables_json!r})
exec_vars = {{}}
exec_vars.update(safe_builtins)
exec_vars.update(safe_modules)
exec_vars.update(user_vars)
stdout_buf = io.StringIO()
stderr_buf = io.StringIO()
result_data = {{"success": True, "result": "", "stdout": "", "stderr": "", "error": ""}}
try:
with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf):
exec({code!r}, exec_vars)
val = exec_vars.get('result', None)
result_data["result"] = str(val) if val is not None else "executed ok"
except Exception as e:
result_data["success"] = False
result_data["error"] = str(e)
result_data["stderr"] = traceback.format_exc()
result_data["stdout"] = stdout_buf.getvalue()
result_data["stderr"] = stderr_buf.getvalue() or result_data.get("stderr", "")
max_out = 500000
if len(result_data["stdout"]) > max_out:
result_data["stdout"] = result_data["stdout"][:max_out] + "\\n... (truncated)"
if len(result_data["stderr"]) > max_out:
result_data["stderr"] = result_data["stderr"][:max_out] + "\\n... (truncated)"
sys.stdout.write(json.dumps(result_data, ensure_ascii=False))
''')
# ──────────────────────────────────────────────
# 主沙箱类
# ──────────────────────────────────────────────
class Sandbox:
"""安全代码执行沙箱 v2
mode:
- 'subprocess': 子进程隔离(默认,最安全)
- 'threaded': 线程内 exec(兼容模式)
"""
def __init__(self, timeout: int = 30,
memory_limit_mb: int = DEFAULT_MEMORY_LIMIT_MB,
whitelist: List[str] = None,
mode: str = 'subprocess',
work_dir: str = None):
self.enabled = True
self.timeout = timeout
self.memory_limit_mb = memory_limit_mb
self.whitelist = whitelist or DEFAULT_WHITELIST
self.mode = mode
self.work_dir = work_dir or tempfile.mkdtemp(prefix='xiaoli_sandbox_')
self.history: List[Dict] = []
self._checker = ASTSecurityChecker()
os.makedirs(self.work_dir, exist_ok=True)
def is_safe(self, code: str) -> Tuple[bool, List[str]]:
"""静态安全检查(AST + 正则)"""
return self._checker.check(code)
def run(self, code: str, variables: dict = None) -> dict:
"""执行代码,返回结果字典(兼容旧接口)"""
result = self.execute(code, variables)
return result.to_dict()
def execute(self, code: str, variables: dict = None) -> SandboxResult:
"""执行代码,返回 SandboxResult"""
if not self.enabled:
return SandboxResult(
success=False, result="sandbox disabled",
security_violation="sandbox is off"
)
safe, violations = self.is_safe(code)
if not safe:
return SandboxResult(
success=False,
result="code failed security check",
security_violation="; ".join(violations[:5])
)
if self.mode == 'subprocess':
result = self._run_subprocess(code, variables)
else:
result = self._run_threaded(code, variables)
self.history.append({
'code': code[:500],
'success': result.success,
'time': result.time,
'timestamp': time.time(),
})
return result
def cleanup(self):
"""清理临时目录"""
try:
if os.path.exists(self.work_dir) and 'xiaoli_sandbox_' in self.work_dir:
shutil.rmtree(self.work_dir, ignore_errors=True)
except Exception:
pass
# ── subprocess ──
def _run_subprocess(self, code: str, variables: dict = None) -> SandboxResult:
vars_json = json.dumps(variables or {}, ensure_ascii=False, default=str)
whitelist_json = json.dumps(self.whitelist)
script = _make_worker_script(code, vars_json, whitelist_json, self.work_dir)
start = time.time()
try:
proc = subprocess.run(
[sys.executable, '-c', script],
capture_output=True,
timeout=self.timeout,
cwd=self.work_dir,
env=self._safe_env(),
preexec_fn=self._set_limits if HAS_RESOURCE else None,
)
elapsed = time.time() - start
stdout = proc.stdout.decode('utf-8', errors='replace')[:DEFAULT_OUTPUT_LIMIT]
stderr = proc.stderr.decode('utf-8', errors='replace')[:DEFAULT_OUTPUT_LIMIT]
try:
data = json.loads(stdout)
return SandboxResult(
success=data.get('success', False),
result=data.get('result', ''),
stdout=data.get('stdout', ''),
stderr=data.get('stderr', ''),
time=elapsed,
exit_code=proc.returncode,
)
except json.JSONDecodeError:
return SandboxResult(
success=proc.returncode == 0,
result=stdout[:2000] if stdout else "no output",
stdout=stdout,
stderr=stderr,
time=elapsed,
exit_code=proc.returncode,
)
except subprocess.TimeoutExpired:
return SandboxResult(
success=False,
result=f"timeout ({self.timeout}s)",
time=self.timeout,
killed=True,
)
except Exception as e:
return SandboxResult(
success=False,
result=f"subprocess error: {e}",
time=time.time() - start,
)
def _safe_env(self) -> dict:
env = {
'PATH': '/usr/bin:/bin',
'LANG': 'en_US.UTF-8',
'LC_ALL': 'en_US.UTF-8',
'HOME': self.work_dir,
'TMPDIR': self.work_dir,
'TEMP': self.work_dir,
'TMP': self.work_dir,
'PYTHONNOUSERSITE': '1',
'PYTHONDONTWRITEBYTECODE': '1',
}
for key in ('TERM', 'COLUMNS', 'LINES', 'SHELL'):
val = os.environ.get(key)
if val:
env[key] = val
return env
@staticmethod
def _set_limits():
if not HAS_RESOURCE:
return
try:
mem_bytes = DEFAULT_MEMORY_LIMIT_MB * 1024 * 1024
resource.setrlimit(resource.RLIMIT_AS, (mem_bytes, mem_bytes))
resource.setrlimit(resource.RLIMIT_CPU, (
DEFAULT_CPU_TIME_LIMIT, DEFAULT_CPU_TIME_LIMIT + 5
))
resource.setrlimit(resource.RLIMIT_NPROC, (0, 0))
resource.setrlimit(resource.RLIMIT_FSIZE, (50 * 1024 * 1024, 50 * 1024 * 1024))
except (ValueError, OSError):
pass
# ── threaded (compat) ──
def _run_threaded(self, code: str, variables: dict = None) -> SandboxResult:
exec_vars = {}
exec_vars.update({
'print': print, 'len': len, 'str': str, 'int': int,
'float': float, 'list': list, 'dict': dict, 'tuple': tuple,
'set': set, 'range': range, 'enumerate': enumerate,
'zip': zip, 'sorted': sorted, 'sum': sum, 'max': max,
'min': min, 'abs': abs, 'round': round, 'type': type,
'isinstance': isinstance,
})
for mod_name in self.whitelist:
try:
exec_vars[mod_name] = __import__(mod_name)
except ImportError:
pass
if variables:
exec_vars.update(variables)
stdout_buf = io.StringIO()
stderr_buf = io.StringIO()
result = {'success': True, 'error': None, 'tb': None}
def _exec():
try:
with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf):
exec(code, exec_vars)
except Exception as e:
result['success'] = False
result['error'] = str(e)
result['tb'] = traceback.format_exc()
start = time.time()
thread = threading.Thread(target=_exec, daemon=True)
thread.start()
thread.join(timeout=self.timeout)
elapsed = time.time() - start
if thread.is_alive():
return SandboxResult(
success=False,
result=f"timeout ({self.timeout}s)",
stdout=stdout_buf.getvalue(),
stderr=stderr_buf.getvalue(),
time=self.timeout,
killed=True,
)
stdout = stdout_buf.getvalue()
stderr = stderr_buf.getvalue()
if result['success']:
val = exec_vars.get('result', None)
return SandboxResult(
success=True,
result=str(val) if val is not None else "executed ok",
stdout=stdout,
stderr=stderr,
time=elapsed,
)
else:
return SandboxResult(
success=False,
result=f"error: {result['error']}",
stdout=stdout,
stderr=stderr + ('\n' + (result['tb'] or '')),
time=elapsed,
)
def __del__(self):
self.cleanup()