ForcePilot/backend/package/yuxi/channel/plugins/sandbox.py
Kris dd1857221c feat(channel/plugins): 新增渠道插件系统完整实现
实现了渠道插件的全生命周期管理能力,包括:
1. 插件清单的加载、解析与存储
2. 依赖校验与拓扑排序安装/卸载
3. 插件发现与安全沙箱执行环境
4. 插件注册中心与目录管理能力
2026-05-21 10:27:58 +08:00

553 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import ast
import builtins
import importlib.util
import logging
import types
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
class SandboxViolationError(Exception):
"""插件代码违反沙箱安全策略时抛出。"""
class PluginSandbox:
"""插件代码沙箱 — 限制动态加载的插件代码只能访问安全的内置函数和模块。
通过 AST 静态分析和运行时全局变量限制,防止插件执行危险操作:
- 禁止文件系统访问open, file 等)
- 禁止系统命令执行os.system, subprocess 等)
- 禁止网络访问socket, urllib 等)
- 禁止导入未授权模块
- 禁止访问私有属性__import__ 等)
"""
# 允许的内置函数白名单
ALLOWED_BUILTINS: set[str] = {
# 基础类型
"bool",
"bytearray",
"bytes",
"complex",
"dict",
"enumerate",
"filter",
"float",
"frozenset",
"int",
"list",
"map",
"memoryview",
"object",
"range",
"set",
"slice",
"str",
"tuple",
"zip",
# 常用函数
"abs",
"all",
"any",
"ascii",
"bin",
"callable",
"chr",
"classmethod",
"compile",
"delattr",
"dir",
"divmod",
"format",
"getattr",
"hasattr",
"hash",
"hex",
"id",
"isinstance",
"issubclass",
"iter",
"len",
"max",
"min",
"next",
"oct",
"ord",
"pow",
"property",
"repr",
"reversed",
"round",
"sorted",
"staticmethod",
"sum",
"vars",
# 异常处理
"BaseException",
"Exception",
"TypeError",
"ValueError",
"AttributeError",
"KeyError",
"IndexError",
"RuntimeError",
"NotImplementedError",
"StopIteration",
"GeneratorExit",
"SystemExit",
"ArithmeticError",
"LookupError",
"AssertionError",
"BufferError",
"EOFError",
"ImportError",
"ModuleNotFoundError",
"NameError",
"OSError",
"OverflowError",
"RecursionError",
"ReferenceError",
"SyntaxError",
"TimeoutError",
"ZeroDivisionError",
# 其他安全函数
"help",
"print",
"input",
"open", # 允许但会在模块级别重写为安全版本
"__build_class__",
"__name__",
"__doc__",
"__package__",
"__spec__",
"__annotations__",
"__dict__",
"__class__",
"__bases__",
"__mro__",
"__subclasses__",
"__init__",
"__new__",
"__setattr__",
"__getattribute__",
"__delattr__",
"__str__",
"__repr__",
"__eq__",
"__ne__",
"__lt__",
"__le__",
"__gt__",
"__ge__",
"__hash__",
"__bool__",
"__len__",
"__iter__",
"__next__",
"__contains__",
"__getitem__",
"__setitem__",
"__delitem__",
"__enter__",
"__exit__",
"__await__",
"__aiter__",
"__anext__",
"__call__",
}
# 禁止导入的模块黑名单
FORBIDDEN_MODULES: set[str] = {
"os",
"sys",
"subprocess",
"socket",
"urllib",
"urllib.request",
"urllib.parse",
"http",
"http.client",
"ftplib",
"smtplib",
"poplib",
"imaplib",
"telnetlib",
"ctypes",
"mmap",
"resource",
"signal",
"pty",
"pickle",
"cPickle",
"marshal",
"imp",
"builtins",
"importlib",
"importlib.util",
"importlib.machinery",
"types",
"inspect",
"code",
"codeop",
"compileall",
"py_compile",
"shutil",
"pathlib",
"tempfile",
"glob",
"fnmatch",
"linecache",
"traceback",
"warnings",
"contextlib",
"atexit",
"gc",
"sysconfig",
"platform",
"pwd",
"grp",
"spwd",
"crypt",
"termios",
"tty",
"fcntl",
"msvcrt",
"winreg",
"winsound",
"posix",
"nt",
"java",
"org",
"sun",
"com",
"net",
}
# 允许导入的模块白名单(如果为空则使用黑名单模式)
ALLOWED_MODULES: set[str] = {
"abc",
"array",
"base64",
"binascii",
"bisect",
"calendar",
"collections",
"collections.abc",
"copy",
"csv",
"dataclasses",
"datetime",
"decimal",
"enum",
"fractions",
"functools",
"heapq",
"html",
"html.entities",
"html.parser",
"io",
"itertools",
"json",
"keyword",
"math",
"numbers",
"operator",
"random",
"re",
"string",
"struct",
"textwrap",
"time",
"typing",
"unicodedata",
"uuid",
"weakref",
"xml.etree.ElementTree",
"xml.dom.minidom",
"xml.sax",
"xml.parsers.expat",
"zoneinfo",
"hashlib",
"hmac",
"secrets",
"statistics",
"stringprep",
"tokenize",
"trace",
"types",
"warnings",
"contextlib",
"copyreg",
"dis",
"opcode",
"pickletools",
"quopri",
"reprlib",
"shelve",
"symtable",
"tabnanny",
"timeit",
"token",
"turtle",
"webbrowser",
"xxlimited",
"xxlimited_35",
"_thread",
"_warnings",
"_weakref",
"_weakrefset",
"_io",
"_collections",
"_collections_abc",
"_functools",
"_operator",
"_stat",
"_string",
"_symtable",
"_typing",
}
def __init__(self) -> None:
self._violation_log: list[str] = []
@property
def violations(self) -> list[str]:
return list(self._violation_log)
def _safe_import(self, name: str, globals_: dict | None = None, locals_: dict | None = None, fromlist: tuple = (), level: int = 0) -> Any:
"""安全的 __import__ 替代函数,限制模块导入。"""
# 解析完整模块名
if level > 0 and globals_ is not None:
package = globals_.get("__package__", "")
if package:
if name:
full_name = f"{package}.{name}"
else:
full_name = package
else:
full_name = name
else:
full_name = name
# 检查是否命中黑名单
for forbidden in self.FORBIDDEN_MODULES:
if full_name == forbidden or full_name.startswith(forbidden + "."):
msg = f"Import of forbidden module '{full_name}' is not allowed in sandbox"
self._violation_log.append(msg)
raise SandboxViolationError(msg)
# 检查是否在白名单中(如果白名单非空)
if self.ALLOWED_MODULES:
allowed = any(
full_name == mod or full_name.startswith(mod + ".")
for mod in self.ALLOWED_MODULES
)
if not allowed:
msg = f"Import of module '{full_name}' is not allowed in sandbox"
self._violation_log.append(msg)
raise SandboxViolationError(msg)
# 使用原始 import 但限制在允许范围内
return __import__(name, globals_, locals_, fromlist, level)
def _safe_open(self, *args: Any, **kwargs: Any) -> Any:
"""安全的 open 替代函数,禁止文件系统访问。"""
msg = "File operations (open) are not allowed in sandbox"
self._violation_log.append(msg)
raise SandboxViolationError(msg)
def _safe_eval(self, *args: Any, **kwargs: Any) -> Any:
"""禁止 eval。"""
msg = "eval() is not allowed in sandbox"
self._violation_log.append(msg)
raise SandboxViolationError(msg)
def _safe_exec(self, *args: Any, **kwargs: Any) -> Any:
"""禁止 exec。"""
msg = "exec() is not allowed in sandbox"
self._violation_log.append(msg)
raise SandboxViolationError(msg)
def _safe_compile(self, *args: Any, **kwargs: Any) -> Any:
"""禁止 compile。"""
msg = "compile() is not allowed in sandbox"
self._violation_log.append(msg)
raise SandboxViolationError(msg)
@staticmethod
def _is_private_attr_name(name: str) -> bool:
"""检查属性名是否为私有属性(双下划线前缀或 Python name mangling 模式)。"""
if name.startswith("__") and not name.endswith("__"):
return True
if "__" in name and not name.startswith("__") and not name.endswith("__"):
return True
return False
def _safe_getattr(self, obj: Any, name: str, default: Any = None) -> Any:
"""安全的 getattr禁止访问双下划线私有属性及 name mangling 绕过。"""
if self._is_private_attr_name(name):
msg = f"Access to private attribute '{name}' is not allowed in sandbox"
self._violation_log.append(msg)
raise SandboxViolationError(msg)
return getattr(obj, name, default)
def _safe_setattr(self, obj: Any, name: str, value: Any) -> None:
"""安全的 setattr禁止修改双下划线私有属性及 name mangling 绕过。"""
if self._is_private_attr_name(name):
msg = f"Modification of private attribute '{name}' is not allowed in sandbox"
self._violation_log.append(msg)
raise SandboxViolationError(msg)
setattr(obj, name, value)
def _safe_delattr(self, obj: Any, name: str) -> None:
"""安全的 delattr禁止删除双下划线私有属性及 name mangling 绕过。"""
if self._is_private_attr_name(name):
msg = f"Deletion of private attribute '{name}' is not allowed in sandbox"
self._violation_log.append(msg)
raise SandboxViolationError(msg)
delattr(obj, name)
def _create_restricted_globals(self) -> dict[str, Any]:
"""创建受限的全局命名空间。"""
restricted_builtins = {}
for name in self.ALLOWED_BUILTINS:
if hasattr(builtins, name):
restricted_builtins[name] = getattr(builtins, name)
# 替换危险函数
restricted_builtins["__import__"] = self._safe_import
restricted_builtins["open"] = self._safe_open
restricted_builtins["eval"] = self._safe_eval
restricted_builtins["exec"] = self._safe_exec
restricted_builtins["compile"] = self._safe_compile
restricted_builtins["getattr"] = self._safe_getattr
restricted_builtins["setattr"] = self._safe_setattr
restricted_builtins["delattr"] = self._safe_delattr
return {"__builtins__": restricted_builtins}
@staticmethod
def validate_source(source: str, filename: str = "<sandbox>") -> ast.AST:
"""静态分析源代码 AST检测危险语法结构不依赖沙箱实例
可用于在任何动态代码执行前进行安全预检,无需创建沙箱实例。
仅执行 AST 级别的静态检查,不涉及运行时全局变量限制。
Args:
source: 要验证的 Python 源代码
filename: 用于错误报告的源文件名
Returns:
解析后的 AST 树
Raises:
SandboxViolationError: 代码包含危险语法结构时
"""
try:
tree = ast.parse(source, filename=filename)
except SyntaxError as e:
raise SandboxViolationError(f"Syntax error in source code: {e}") from e
for node in ast.walk(tree):
if isinstance(node, ast.Call):
if isinstance(node.func, ast.Name) and node.func.id == "__import__":
raise SandboxViolationError(
"Direct __import__() calls are not allowed in sandbox"
)
if isinstance(node, ast.ImportFrom):
if node.names[0].name == "*":
raise SandboxViolationError(
"'from ... import *' is not allowed in sandbox"
)
return tree
def _validate_ast(self, source: str, filename: str = "<sandbox>") -> ast.AST:
"""静态分析 AST检测危险语法结构实例方法会记录违规日志"""
try:
tree = self.validate_source(source, filename)
except SandboxViolationError as e:
self._violation_log.append(str(e))
raise
return tree
def execute(self, source: str, filename: str = "<sandbox>", extra_globals: dict[str, Any] | None = None) -> dict[str, Any]:
"""在沙箱中执行 Python 源代码,返回执行后的全局命名空间。
Args:
source: 要执行的 Python 源代码
filename: 用于错误报告的源文件名
extra_globals: 额外注入到全局命名空间的安全对象
Returns:
执行后的全局命名空间字典
Raises:
SandboxViolationError: 代码违反安全策略时
"""
# AST 静态分析
tree = self._validate_ast(source, filename)
# 编译为代码对象
code = compile(tree, filename, "exec")
# 创建受限全局环境
restricted_globals = self._create_restricted_globals()
if extra_globals:
# 只允许注入安全的额外对象
for key, value in extra_globals.items():
if not key.startswith("__") or key in ("__name__", "__file__", "__doc__", "__package__", "__spec__"):
restricted_globals[key] = value
# 执行代码
exec(code, restricted_globals) # noqa: S102
return restricted_globals
def load_module_from_source(self, source: str, module_name: str, filename: str = "<sandbox>") -> types.ModuleType:
"""在沙箱中从源代码创建模块。
Args:
source: 模块源代码
module_name: 模块名称
filename: 用于错误报告的源文件名
Returns:
创建的模块对象
"""
tree = self._validate_ast(source, filename)
code = compile(tree, filename, "exec")
module = types.ModuleType(module_name)
module.__dict__.update(self._create_restricted_globals())
module.__name__ = module_name
module.__file__ = filename
exec(code, module.__dict__) # noqa: S102
return module
def load_module_from_file(self, file_path: str | Path, module_name: str | None = None) -> types.ModuleType:
"""在沙箱中从文件加载模块。
Args:
file_path: 文件路径
module_name: 模块名称(默认为文件名)
Returns:
创建的模块对象
"""
file_path = Path(file_path)
if not file_path.exists():
raise FileNotFoundError(f"Plugin file not found: {file_path}")
source = file_path.read_text(encoding="utf-8")
name = module_name or file_path.stem
return self.load_module_from_source(source, name, str(file_path))