feat(channel/plugins): 新增渠道插件系统完整实现

实现了渠道插件的全生命周期管理能力,包括:
1. 插件清单的加载、解析与存储
2. 依赖校验与拓扑排序安装/卸载
3. 插件发现与安全沙箱执行环境
4. 插件注册中心与目录管理能力
This commit is contained in:
Kris 2026-05-21 10:27:58 +08:00
parent 79c6e6b74f
commit dd1857221c
9 changed files with 1713 additions and 0 deletions

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,87 @@
from __future__ import annotations
import logging
from typing import Any
from .discovery import ChannelPluginDiscovery
from .install import install_channel_plugin
from .manifest import ChannelPluginManifest, load_manifests_from_directory
from .registry import ChannelPluginRegistry
logger = logging.getLogger(__name__)
class ChannelPluginCatalog:
"""渠道插件目录 — 管理可用插件的注册、安装与查询。"""
def __init__(self, manifest_dir: str | None = None):
self._manifest_dir = manifest_dir
self._manifests: dict[str, ChannelPluginManifest] = {}
self._installed: set[str] = set()
def load_manifests(self) -> None:
if not self._manifest_dir:
return
manifests = load_manifests_from_directory(self._manifest_dir)
for manifest in manifests:
self._manifests[manifest.id] = manifest
logger.info("Loaded %d plugin manifests", len(self._manifests))
def get_manifest(self, plugin_id: str) -> ChannelPluginManifest | None:
return self._manifests.get(plugin_id)
def list_manifests(self) -> list[ChannelPluginManifest]:
return list(self._manifests.values())
def list_available(self) -> list[str]:
return list(self._manifests.keys())
def list_installed(self) -> list[str]:
return list(self._installed)
def is_installed(self, plugin_id: str) -> bool:
return plugin_id in self._installed
def install(self, plugin_id: str) -> bool:
manifest = self._manifests.get(plugin_id)
if not manifest:
logger.warning("Cannot install plugin '%s': manifest not found", plugin_id)
return False
try:
install_channel_plugin(manifest)
self._installed.add(plugin_id)
logger.info("Installed channel plugin: %s", plugin_id)
return True
except Exception:
logger.exception("Failed to install plugin '%s'", plugin_id)
return False
def uninstall(self, plugin_id: str) -> bool:
if plugin_id not in self._installed:
return False
self._installed.discard(plugin_id)
manifest = self._manifests.get(plugin_id)
target_dir = manifest.target_path if manifest else None
dependencies = manifest.dependencies if manifest else None
from .install import uninstall_channel_plugin
uninstall_channel_plugin(
plugin_id=plugin_id,
target_dir=target_dir,
dependencies=dependencies,
)
return True
def discover_builtin(self, package_name: str = "yuxi.channels") -> list[Any]:
discovered = ChannelPluginDiscovery.discover_builtin(package_name)
for plugin in discovered:
self._installed.add(getattr(plugin, "id"))
return discovered
def discover_from_path(self, plugin_path: str) -> list[Any]:
discovered = ChannelPluginDiscovery.discover_from_path(plugin_path)
for plugin in discovered:
self._installed.add(getattr(plugin, "id"))
return discovered

View File

@ -0,0 +1,131 @@
from __future__ import annotations
import logging
from collections import defaultdict, deque
from typing import Any
from .manifest import ChannelPluginManifest
logger = logging.getLogger(__name__)
class DependencyGraph:
"""渠道插件依赖图 — 检测循环依赖并计算拓扑排序安装顺序。"""
def __init__(self):
self._nodes: dict[str, ChannelPluginManifest] = {}
self._edges: dict[str, set[str]] = defaultdict(set)
self._reverse_edges: dict[str, set[str]] = defaultdict(set)
def add(self, manifest: ChannelPluginManifest) -> None:
self._nodes[manifest.id] = manifest
for dep in manifest.dependencies:
dep_id = dep if isinstance(dep, str) else dep.get("id", "")
if dep_id:
self._edges[manifest.id].add(dep_id)
self._reverse_edges[dep_id].add(manifest.id)
def remove(self, plugin_id: str) -> None:
if plugin_id not in self._nodes:
return
del self._nodes[plugin_id]
for dep_id in list(self._edges.get(plugin_id, [])):
self._reverse_edges[dep_id].discard(plugin_id)
self._edges.pop(plugin_id, None)
for other_id, deps in list(self._edges.items()):
if plugin_id in deps:
deps.discard(plugin_id)
self._reverse_edges[plugin_id].discard(other_id)
def get_dependencies(self, plugin_id: str) -> set[str]:
return set(self._edges.get(plugin_id, []))
def get_dependents(self, plugin_id: str) -> set[str]:
return set(self._reverse_edges.get(plugin_id, []))
def has_cycle(self) -> tuple[bool, list[str]]:
"""检测是否存在循环依赖,返回 (是否有环, 环中的节点列表)。"""
visited: set[str] = set()
rec_stack: set[str] = set()
cycle: list[str] = []
def dfs(node: str, path: list[str]) -> bool:
visited.add(node)
rec_stack.add(node)
path.append(node)
for neighbor in self._edges.get(node, set()):
if neighbor not in visited:
if dfs(neighbor, path):
return True
elif neighbor in rec_stack:
cycle_start = path.index(neighbor)
cycle.extend(path[cycle_start:])
return True
path.pop()
rec_stack.remove(node)
return False
for node in self._nodes:
if node not in visited:
if dfs(node, []):
return True, cycle
return False, []
def topological_sort(self) -> list[str]:
"""返回拓扑排序后的插件 ID 列表。如果存在循环依赖,抛出 ValueError。"""
has_cycle, cycle = self.has_cycle()
if has_cycle:
raise ValueError(f"Circular dependency detected: {' -> '.join(cycle)}")
in_degree = {node: 0 for node in self._nodes}
for node, deps in self._edges.items():
for dep in deps:
if dep in in_degree:
in_degree[node] += 1
queue = deque(node for node, degree in in_degree.items() if degree == 0)
result = []
while queue:
node = queue.popleft()
result.append(node)
for dependent in self._reverse_edges.get(node, set()):
if dependent in in_degree:
in_degree[dependent] -= 1
if in_degree[dependent] == 0:
queue.append(dependent)
if len(result) != len(self._nodes):
raise ValueError("Dependency graph has unresolved dependencies")
return result
def install_order(self) -> list[str]:
"""获取安装顺序(依赖优先)。"""
return self.topological_sort()
def uninstall_order(self) -> list[str]:
"""获取卸载顺序(反向拓扑排序,被依赖者优先)。"""
return list(reversed(self.topological_sort()))
def get_subgraph(self, plugin_id: str) -> "DependencyGraph":
"""获取包含指定插件及其所有依赖的子图。"""
subgraph = DependencyGraph()
visited: set[str] = set()
def visit(node: str) -> None:
if node in visited or node not in self._nodes:
return
visited.add(node)
subgraph.add(self._nodes[node])
for dep in self._edges.get(node, set()):
visit(dep)
visit(plugin_id)
return subgraph
def to_dict(self) -> dict[str, Any]:
return {
"nodes": list(self._nodes.keys()),
"edges": {k: list(v) for k, v in self._edges.items()},
}

View File

@ -0,0 +1,287 @@
from __future__ import annotations
import logging
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class AllowedPackage:
"""白名单中的允许安装包配置。"""
name: str
min_version: str | None = None
max_version: str | None = None
allowed_versions: list[str] | None = None
blocked_versions: list[str] | None = None
_BUILTIN_ALLOWED = {
"aiofiles": AllowedPackage(name="aiofiles", min_version="0.8.0"),
"aiohttp": AllowedPackage(name="aiohttp", min_version="3.8.0"),
"aiosignal": AllowedPackage(name="aiosignal", min_version="1.2.0"),
"asyncpg": AllowedPackage(name="asyncpg", min_version="0.28.0"),
"cachetools": AllowedPackage(name="cachetools", min_version="5.0.0"),
"cryptography": AllowedPackage(name="cryptography", min_version="41.0.0"),
"fastapi": AllowedPackage(name="fastapi", min_version="0.100.0"),
"frozenlist": AllowedPackage(name="frozenlist", min_version="1.3.0"),
"httpx": AllowedPackage(name="httpx", min_version="0.24.0"),
"lxml": AllowedPackage(name="lxml", min_version="4.9.0"),
"multidict": AllowedPackage(name="multidict", min_version="6.0.0"),
"numpy": AllowedPackage(name="numpy", min_version="1.24.0"),
"orjson": AllowedPackage(name="orjson", min_version="3.9.0"),
"pillow": AllowedPackage(name="pillow", min_version="10.0.0"),
"psycopg2-binary": AllowedPackage(name="psycopg2-binary", min_version="2.9.0"),
"pydantic": AllowedPackage(name="pydantic", min_version="2.0.0"),
"pydantic-core": AllowedPackage(name="pydantic-core", min_version="2.0.0"),
"pydantic-settings": AllowedPackage(name="pydantic-settings", min_version="2.0.0"),
"python-dotenv": AllowedPackage(name="python-dotenv", min_version="1.0.0"),
"pyyaml": AllowedPackage(name="pyyaml", min_version="6.0"),
"redis": AllowedPackage(name="redis", min_version="4.5.0"),
"requests": AllowedPackage(name="requests", min_version="2.28.0"),
"sqlalchemy": AllowedPackage(name="sqlalchemy", min_version="2.0.0"),
"starlette": AllowedPackage(name="starlette", min_version="0.27.0"),
"structlog": AllowedPackage(name="structlog", min_version="23.0.0"),
"tenacity": AllowedPackage(name="tenacity", min_version="8.0.0"),
"urllib3": AllowedPackage(name="urllib3", min_version="1.26.0"),
"uvicorn": AllowedPackage(name="uvicorn", min_version="0.22.0"),
"websockets": AllowedPackage(name="websockets", min_version="10.0"),
"yarl": AllowedPackage(name="yarl", min_version="1.8.0"),
}
@dataclass
class ValidationResult:
"""依赖校验结果。"""
is_valid: bool
package: str
requested_spec: str
errors: list[str] = field(default_factory=list)
class DependencyValidator:
"""渠道插件依赖校验器 —— 防止恶意包安装。
功能
1. 白名单校验只允许安装预审批的包
2. 版本约束校验确保版本符合安全策略
3. 包名规范化防止依赖混淆攻击
"""
_PEP508_NAME_RE = re.compile(r"^[A-Za-z0-9][-A-Za-z0-9._]*$")
_VERSION_SPEC_RE = re.compile(
r"^[A-Za-z0-9][-A-Za-z0-9._]*" # 包名
r"(\s*\[\s*[A-Za-z0-9_,\s-]+\s*\])?" # extras
r"(\s*(==|!=|<=|>=|<|>|~=|===)\s*[^\s;]+)*" # 版本约束
r"(\s*;.*)?$" # 环境标记
)
def __init__(self, allowed_packages: dict[str, AllowedPackage] | None = None):
self._allowed: dict[str, AllowedPackage] = {}
if allowed_packages:
for name, cfg in allowed_packages.items():
self._allowed[self._normalize_name(name)] = cfg
@classmethod
def from_config_file(cls, config_path: str | Path) -> "DependencyValidator":
"""从 JSON 配置文件加载白名单。"""
import json
path = Path(config_path)
if not path.exists():
logger.warning("Dependency whitelist config not found: %s", path)
return cls()
try:
data = json.loads(path.read_text(encoding="utf-8"))
allowed = {}
for item in data.get("allowed_packages", []):
name = item["name"]
allowed[name] = AllowedPackage(
name=name,
min_version=item.get("min_version"),
max_version=item.get("max_version"),
allowed_versions=item.get("allowed_versions"),
blocked_versions=item.get("blocked_versions"),
)
return cls(allowed)
except Exception:
logger.exception("Failed to load dependency whitelist from %s", path)
return cls()
@classmethod
def default_validator(cls) -> "DependencyValidator":
"""获取默认校验器。
加载优先级
1. 环境变量 YUXI_DEPENDENCY_WHITELIST 指定的文件
2. 统一配置 yuxi.config.config.dependency_whitelist
3. 内置硬编码白名单降级
"""
whitelist_file = os.getenv("YUXI_DEPENDENCY_WHITELIST")
if whitelist_file:
path = Path(whitelist_file)
if path.exists():
logger.info("Loading dependency whitelist from env: %s", path)
return cls.from_config_file(path)
try:
from yuxi.config import config
allowed: dict[str, AllowedPackage] = {}
for entry in config.dependency_whitelist:
allowed[entry.name] = AllowedPackage(
name=entry.name,
min_version=entry.min_version,
max_version=entry.max_version,
allowed_versions=entry.allowed_versions,
blocked_versions=entry.blocked_versions,
)
logger.info("Loaded dependency whitelist from unified config (%d packages)", len(allowed))
return cls(allowed)
except Exception:
logger.exception("Failed to load dependency whitelist from unified config, using builtin defaults")
logger.info("Using builtin dependency whitelist defaults (%d packages)", len(_BUILTIN_ALLOWED))
return cls(dict(_BUILTIN_ALLOWED))
def validate(self, dependency_spec: str) -> ValidationResult:
"""校验单个依赖规格是否允许安装。"""
dependency_spec = dependency_spec.strip()
if not dependency_spec:
return ValidationResult(
is_valid=False,
package="",
requested_spec=dependency_spec,
errors=["Empty dependency specification"],
)
if not self._VERSION_SPEC_RE.match(dependency_spec):
return ValidationResult(
is_valid=False,
package="",
requested_spec=dependency_spec,
errors=["Invalid dependency specification format"],
)
package_name = self._extract_package_name(dependency_spec)
normalized = self._normalize_name(package_name)
if normalized not in self._allowed:
return ValidationResult(
is_valid=False,
package=package_name,
requested_spec=dependency_spec,
errors=[f"Package '{package_name}' is not in the allowed whitelist"],
)
allowed = self._allowed[normalized]
version_constraints = self._extract_version_constraints(dependency_spec)
errors = []
for op, ver in version_constraints:
if not self._is_version_allowed(allowed, op, ver):
errors.append(
f"Version constraint '{op}{ver}' violates policy for '{package_name}'"
)
if allowed.blocked_versions:
for _, ver in version_constraints:
if ver in allowed.blocked_versions:
errors.append(
f"Version '{ver}' is explicitly blocked for '{package_name}'"
)
return ValidationResult(
is_valid=len(errors) == 0,
package=package_name,
requested_spec=dependency_spec,
errors=errors,
)
def validate_all(self, dependencies: list[str]) -> list[ValidationResult]:
"""批量校验依赖列表。"""
return [self.validate(dep) for dep in dependencies]
def is_allowed(self, dependency_spec: str) -> bool:
"""快速判断单个依赖是否允许。"""
return self.validate(dependency_spec).is_valid
@staticmethod
def _normalize_name(name: str) -> str:
"""规范化包名PEP 503转小写替换 _ 和 . 为 -。"""
return name.lower().replace("_", "-").replace(".", "-")
@staticmethod
def _extract_package_name(spec: str) -> str:
"""从依赖规格中提取包名。"""
spec = spec.strip()
match = re.match(r"^([A-Za-z0-9][-A-Za-z0-9._]*)", spec)
if match:
return match.group(1)
return spec.split("[")[0].split(";")[0].strip()
@staticmethod
def _extract_version_constraints(spec: str) -> list[tuple[str, str]]:
"""从依赖规格中提取版本约束列表 [(operator, version), ...]。"""
constraints = []
pattern = re.compile(r"(==|!=|<=|>=|<|>|~=|===)\s*([^\s;,]+)")
for match in pattern.finditer(spec):
constraints.append((match.group(1), match.group(2)))
return constraints
@staticmethod
def _is_version_allowed(allowed: AllowedPackage, op: str, version: str) -> bool:
"""检查版本约束是否符合白名单策略。"""
if allowed.allowed_versions and version not in allowed.allowed_versions:
return False
if allowed.min_version and op in (">=", ">", "=="):
if not DependencyValidator._version_gte(version, allowed.min_version):
return False
if allowed.max_version and op in ("<=", "<", "=="):
if not DependencyValidator._version_lte(version, allowed.max_version):
return False
return True
@staticmethod
def _version_gte(v1: str, v2: str) -> bool:
"""简化版本比较v1 >= v2。"""
try:
return DependencyValidator._parse_version(v1) >= DependencyValidator._parse_version(v2)
except ValueError:
return True
@staticmethod
def _version_lte(v1: str, v2: str) -> bool:
"""简化版本比较v1 <= v2。"""
try:
return DependencyValidator._parse_version(v1) <= DependencyValidator._parse_version(v2)
except ValueError:
return True
@staticmethod
def _parse_version(version: str) -> tuple[int, ...]:
"""解析版本号为可比较的元组。"""
version = version.strip()
parts = re.split(r"[.-]", version)
result = []
for part in parts:
if part.isdigit():
result.append(int(part))
else:
match = re.match(r"(\d+)", part)
if match:
result.append(int(match.group(1)))
break
if not result:
raise ValueError(f"Invalid version: {version}")
return tuple(result)

View File

@ -0,0 +1,291 @@
from __future__ import annotations
import importlib
import logging
import os
import pkgutil
import threading
from pathlib import Path
from typing import Any
from yuxi.channel.protocols import MetaProtocol
from .manifest import ChannelPluginManifest
from .registry import ChannelPluginRegistry
from .sandbox import PluginSandbox, SandboxViolationError
logger = logging.getLogger(__name__)
_CacheEntry = tuple[float, float, list[Path]]
class ChannelPluginDiscovery:
"""自动发现项目中的渠道插件。"""
_path_allowlist: list[str] | None = None
_path_allowlist_loaded: bool = False
_discovery_cache: dict[str, _CacheEntry] = {}
_discovery_cache_lock: threading.Lock | None = None
@classmethod
def _ensure_path_allowlist_loaded(cls) -> None:
if cls._path_allowlist_loaded:
return
cls._path_allowlist_loaded = True
cls._load_path_allowlist_from_env()
@classmethod
def _load_path_allowlist_from_env(cls) -> None:
allowed = []
env_val = os.getenv("YUXI_PLUGIN_ALLOWLIST")
if env_val:
for entry in env_val.split(os.pathsep):
entry = entry.strip()
if entry:
allowed.append(str(Path(entry).resolve()))
extensions_dir = Path(__file__).resolve().parent.parent / "extensions"
if extensions_dir.exists():
allowed.append(str(extensions_dir))
cls._path_allowlist = allowed
@classmethod
def set_path_allowlist(cls, paths: list[str]) -> None:
cls._path_allowlist = [str(Path(p).resolve()) for p in paths]
cls._path_allowlist_loaded = True
@classmethod
def get_path_allowlist(cls) -> list[str]:
cls._ensure_path_allowlist_loaded()
return list(cls._path_allowlist) if cls._path_allowlist else []
@classmethod
def clear_path_allowlist(cls) -> None:
cls._path_allowlist = None
cls._path_allowlist_loaded = False
@classmethod
def _ensure_cache_lock(cls) -> threading.Lock:
if cls._discovery_cache_lock is None:
cls._discovery_cache_lock = threading.Lock()
return cls._discovery_cache_lock
@classmethod
def clear_discovery_cache(cls) -> None:
with cls._ensure_cache_lock():
cls._discovery_cache.clear()
@classmethod
def _is_path_allowed(cls, path: Path) -> bool:
cls._ensure_path_allowlist_loaded()
if not cls._path_allowlist:
return False
resolved = str(path.resolve())
return any(resolved == allowed or resolved.startswith(allowed + os.sep) for allowed in cls._path_allowlist)
@classmethod
def validate_plugin_path(cls, path: str | Path, purpose: str = "load") -> Path:
"""验证插件路径安全性并返回解析后的路径。
校验规则
1. 路径不能包含 ``..`` 组件防御深度防止目录遍历
2. 路径必须在白名单目录内
3. 白名单为空时拒绝所有路径安全默认
Args:
path: 要验证的路径
purpose: 操作目的描述用于日志和错误消息
Returns:
解析后的绝对路径
Raises:
PermissionError: 路径校验失败存在安全风险
"""
cls._ensure_path_allowlist_loaded()
raw_path = Path(path)
if ".." in raw_path.parts:
logger.warning(
"Path traversal attempt blocked for %s: '%s' contains '..' component",
purpose, path,
)
raise PermissionError(
f"Path traversal not allowed for plugin {purpose}: '{path}'"
)
resolved = raw_path.resolve()
if not cls._path_allowlist:
logger.warning(
"Plugin path rejected for %s (allowlist is empty): %s",
purpose, resolved,
)
raise PermissionError(
f"Plugin {purpose} rejected: no allowed directories configured"
)
if not cls._is_path_allowed(resolved):
logger.warning(
"Plugin path not in allowlist for %s: '%s' (resolved: %s)",
purpose, path, resolved,
)
raise PermissionError(
f"Plugin path not in allowlist for {purpose}: '{resolved}'"
)
return resolved
@classmethod
def discover_builtin(cls, package_name: str = "yuxi.channels") -> list[Any]:
"""扫描指定 Python 包下的所有模块,注册实现了 ConfigProtocol 的类。
导入前对每个模块源文件执行 AST 静态安全分析
拦截包含 ``__import__()`` 调用``from ... import *`` 等危险语法结构的模块
"""
discovered = []
try:
package = importlib.import_module(package_name)
except ImportError:
logger.warning("Cannot import package '%s' for plugin discovery", package_name)
return discovered
package_path = getattr(package, "__path__", None)
if not package_path:
logger.warning("Package '%s' has no __path__", package_name)
return discovered
for _, module_name, is_pkg in pkgutil.iter_modules(package_path):
if is_pkg:
continue
full_name = f"{package_name}.{module_name}"
module_file = Path(package_path[0]) / f"{module_name}.py"
if module_file.exists():
try:
source = module_file.read_text(encoding="utf-8")
PluginSandbox.validate_source(source, str(module_file))
except SandboxViolationError:
logger.warning(
"Builtin plugin '%s' failed AST security validation: %s",
full_name, module_file,
)
continue
try:
module = importlib.import_module(full_name)
except Exception:
logger.exception("Failed to import module %s", full_name)
continue
for attr_name in dir(module):
obj = getattr(module, attr_name)
if not isinstance(obj, type):
continue
if getattr(obj, "__module__", None) != full_name:
continue
if not isinstance(obj, MetaProtocol):
continue
plugin_id = getattr(obj, "id", None)
if not plugin_id or not isinstance(plugin_id, str) or not plugin_id.strip():
continue
try:
ChannelPluginRegistry.register(obj)
discovered.append(obj)
except ValueError:
continue
return discovered
@classmethod
def discover_from_path(cls, plugin_path: str | Path) -> list[Any]:
"""从文件系统路径加载插件模块(带路径白名单校验、扫描缓存和沙箱执行)。"""
discovered = []
plugin_path = Path(plugin_path)
if not plugin_path.exists():
logger.warning("Plugin path does not exist: %s", plugin_path)
return discovered
if not cls._is_path_allowed(plugin_path):
logger.warning("Plugin path not in allowlist: %s", plugin_path)
return discovered
if plugin_path.is_file() and plugin_path.suffix == ".py":
module_files = [plugin_path]
elif plugin_path.is_dir():
module_files = cls._scan_with_cache(plugin_path)
else:
return discovered
sandbox = PluginSandbox()
for file_path in module_files:
try:
module = sandbox.load_module_from_file(file_path, file_path.stem)
except Exception:
logger.exception("Failed to load plugin from %s", file_path)
continue
for attr_name in dir(module):
obj = getattr(module, attr_name)
if not isinstance(obj, type):
continue
if getattr(obj, "__module__", None) != module.__name__:
continue
if not isinstance(obj, MetaProtocol):
continue
plugin_id = getattr(obj, "id", None)
if not plugin_id or not isinstance(plugin_id, str) or not plugin_id.strip():
continue
try:
ChannelPluginRegistry.register(obj)
discovered.append(obj)
except ValueError:
continue
return discovered
@classmethod
def _scan_with_cache(cls, plugin_path: Path) -> list[Path]:
resolved = str(plugin_path.resolve())
with cls._ensure_cache_lock():
cached = cls._discovery_cache.get(resolved)
if cached is not None:
_max_mtime, dir_mtime, files = cached
try:
if plugin_path.stat().st_mtime <= dir_mtime and cls._cache_still_valid(files, _max_mtime):
return list(files)
except OSError:
pass
module_files = list(plugin_path.rglob("*.py"))
max_mtime = max(
(f.stat().st_mtime for f in module_files),
default=0.0,
)
try:
dir_mtime = plugin_path.stat().st_mtime
except OSError:
dir_mtime = 0.0
with cls._ensure_cache_lock():
cls._discovery_cache[resolved] = (max_mtime, dir_mtime, module_files)
return module_files
@staticmethod
def _cache_still_valid(files: list[Path], cached_max_mtime: float) -> bool:
for f in files:
try:
if f.stat().st_mtime > cached_max_mtime:
return False
except OSError:
return False
return True
@classmethod
def discover_manifests(cls, manifest_dir: str | Path) -> list[ChannelPluginManifest]:
"""从清单目录加载插件元数据。"""
from .manifest import load_manifests_from_directory
return load_manifests_from_directory(manifest_dir)

View File

@ -0,0 +1,169 @@
from __future__ import annotations
import logging
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any
from .dependency_validator import DependencyValidator
from .discovery import ChannelPluginDiscovery
from .manifest import ChannelPluginManifest
logger = logging.getLogger(__name__)
class SecurityError(Exception):
"""安全校验错误 —— 阻断不安全的插件安装。"""
def install_channel_plugin(
manifest: ChannelPluginManifest,
validator: DependencyValidator | None = None,
) -> None:
"""安装一个渠道插件:
1. 安装依赖 (pip install) 带白名单校验
2. 复制/链接插件代码到目标目录
3. 发现并注册插件类
Args:
manifest: 插件清单
validator: 依赖校验器 None 时使用默认校验器
"""
logger.info("Installing channel plugin: %s", manifest.id)
if manifest.dependencies:
logger.info("Installing dependencies for %s: %s", manifest.id, manifest.dependencies)
_install_dependencies_with_validation(manifest.dependencies, validator)
if manifest.source_path:
try:
source = ChannelPluginDiscovery.validate_plugin_path(manifest.source_path, purpose="source copy")
except PermissionError as e:
raise SecurityError(str(e)) from e
target = Path(manifest.target_path or f"yuxi/channels/{manifest.id}")
if source.exists():
if target.exists():
shutil.rmtree(target)
if source.is_dir():
shutil.copytree(source, target)
else:
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
logger.info("Copied plugin source from %s to %s", source, target)
if manifest.entry_point:
try:
entry_path = ChannelPluginDiscovery.validate_plugin_path(manifest.entry_point, purpose="entry point")
except PermissionError as e:
raise SecurityError(str(e)) from e
ChannelPluginDiscovery.discover_from_path(entry_path)
logger.info("Channel plugin '%s' installed successfully", manifest.id)
def _install_dependencies_with_validation(
dependencies: list[str],
validator: DependencyValidator | None = None,
) -> None:
"""带白名单校验的依赖安装。
校验流程
1. 使用 DependencyValidator 校验每个依赖
2. 收集所有校验错误
3. 如有错误抛出 SecurityError 阻断安装
4. 校验通过后执行 pip install
"""
validator = validator or DependencyValidator.default_validator()
results = validator.validate_all(dependencies)
errors = [err for r in results for err in r.errors]
if errors:
for err in errors:
logger.error("Dependency validation failed: %s", err)
raise SecurityError(f"Dependency validation failed: {'; '.join(errors)}")
allowed_deps = [r.requested_spec for r in results if r.is_valid]
if not allowed_deps:
logger.info("No valid dependencies to install after validation")
return
logger.info("Installing validated dependencies: %s", allowed_deps)
try:
pip_cmd = [
sys.executable, "-m", "pip", "install",
"--no-build-isolation",
*allowed_deps,
]
subprocess.check_output(
pip_cmd,
stderr=subprocess.STDOUT,
text=True,
)
except subprocess.CalledProcessError as e:
logger.error("pip install failed: %s", e.output)
raise SecurityError(f"Failed to install dependencies: {e.output}") from e
def uninstall_channel_plugin(
plugin_id: str,
target_dir: str | Path | None = None,
dependencies: list[str] | None = None,
) -> bool:
"""卸载一个渠道插件:
1. Registry 中注销
2. 清理文件系统中的插件文件
3. 卸载 pip 安装的依赖尽力而为不阻断卸载流程
Args:
plugin_id: 插件 ID
target_dir: 插件文件所在目录 manifest.target_path 提取
dependencies: 插件依赖列表 manifest.dependencies 提取
"""
from .registry import ChannelPluginRegistry
ChannelPluginRegistry.unregister(plugin_id)
if target_dir:
target = Path(target_dir)
if target.exists():
shutil.rmtree(target)
logger.info("Removed plugin directory: %s", target)
if dependencies:
_uninstall_dependencies(plugin_id, dependencies)
logger.info("Channel plugin '%s' fully uninstalled", plugin_id)
return True
def _uninstall_dependencies(plugin_id: str, dependencies: list[str]) -> None:
"""卸载插件依赖(尽力而为:失败仅记录告警,不抛异常)。"""
if not dependencies:
return
package_names = []
for dep in dependencies:
name = dep.strip().split("=")[0].split(">")[0].split("<")[0].split("~")[0].split("!")[0].strip()
name = name.split("[")[0].split(";")[0].strip()
if name:
package_names.append(name)
if not package_names:
return
logger.info("Uninstalling dependencies for '%s': %s", plugin_id, package_names)
try:
subprocess.check_output(
[sys.executable, "-m", "pip", "uninstall", "-y", *package_names],
stderr=subprocess.STDOUT,
text=True,
)
logger.info("Successfully uninstalled dependencies for '%s'", plugin_id)
except subprocess.CalledProcessError as e:
logger.warning(
"Failed to uninstall dependencies for '%s': %s (plugin already unregistered)",
plugin_id, e.output.strip() if e.output else str(e),
)

View File

@ -0,0 +1,89 @@
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
@dataclass
class ChannelPluginManifest:
id: str
name: str
version: str
description: str = ""
author: str = ""
dependencies: list[str] = field(default_factory=list)
entry_point: str | None = None
source_path: str | None = None
target_path: str | None = None
config_schema: dict[str, Any] = field(default_factory=dict)
capabilities: list[str] = field(default_factory=list)
tags: list[str] = field(default_factory=list)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ChannelPluginManifest":
return cls(
id=data["id"],
name=data.get("name", data["id"]),
version=data.get("version", "0.0.1"),
description=data.get("description", ""),
author=data.get("author", ""),
dependencies=data.get("dependencies", []),
entry_point=data.get("entry_point"),
source_path=data.get("source_path"),
target_path=data.get("target_path"),
config_schema=data.get("config_schema", {}),
capabilities=data.get("capabilities", []),
tags=data.get("tags", []),
)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"version": self.version,
"description": self.description,
"author": self.author,
"dependencies": self.dependencies,
"entry_point": self.entry_point,
"source_path": self.source_path,
"target_path": self.target_path,
"config_schema": self.config_schema,
"capabilities": self.capabilities,
"tags": self.tags,
}
def load_manifests_from_directory(manifest_dir: str | Path) -> list[ChannelPluginManifest]:
"""从目录加载所有插件清单文件 (.json)。"""
manifest_dir = Path(manifest_dir)
if not manifest_dir.exists():
logger.warning("Manifest directory does not exist: %s", manifest_dir)
return []
manifests = []
for file_path in manifest_dir.glob("*.json"):
try:
data = json.loads(file_path.read_text(encoding="utf-8"))
if isinstance(data, list):
for item in data:
manifests.append(ChannelPluginManifest.from_dict(item))
else:
manifests.append(ChannelPluginManifest.from_dict(data))
except Exception:
logger.exception("Failed to load manifest from %s", file_path)
return manifests
def save_manifest(manifest: ChannelPluginManifest, manifest_dir: str | Path) -> Path:
"""保存插件清单到指定目录。"""
manifest_dir = Path(manifest_dir)
manifest_dir.mkdir(parents=True, exist_ok=True)
file_path = manifest_dir / f"{manifest.id}.json"
file_path.write_text(json.dumps(manifest.to_dict(), indent=2, ensure_ascii=False), encoding="utf-8")
logger.info("Saved manifest to %s", file_path)
return file_path

View File

@ -0,0 +1,106 @@
from __future__ import annotations
import logging
import threading
from typing import Any, Iterator
from yuxi.channel.protocols import MetaProtocol
logger = logging.getLogger(__name__)
class _RegistryMeta(type):
def __len__(cls) -> int:
return len(cls._plugins)
def __iter__(cls) -> Iterator[Any]:
return iter(
sorted(
cls._plugins.values(),
key=lambda p: (getattr(p, "order", 0), getattr(p, "id", "")),
)
)
def __contains__(cls, plugin_id: str) -> bool:
return plugin_id in cls._plugins
def __repr__(cls) -> str:
if not cls._plugins:
return "<ChannelPluginRegistry empty>"
ids = sorted(cls._plugins.keys())
preview = ", ".join(ids[:5])
tail = "..." if len(ids) > 5 else ""
return f"<ChannelPluginRegistry {len(cls._plugins)} plugins: {preview}{tail}>"
class ChannelPluginRegistry(metaclass=_RegistryMeta):
_plugins: dict[str, Any] = {}
_lock: threading.Lock | None = None
@classmethod
def _ensure_lock(cls) -> threading.Lock:
if cls._lock is None:
cls._lock = threading.Lock()
return cls._lock
@classmethod
def register(cls, plugin: Any) -> Any:
if not isinstance(plugin, MetaProtocol):
raise ValueError("Channel plugin must satisfy MetaProtocol (id, name, order)")
plugin_id = getattr(plugin, "id", None)
if not plugin_id or not isinstance(plugin_id, str) or not plugin_id.strip():
raise ValueError("Channel plugin must have a non-empty string 'id' attribute")
with cls._ensure_lock():
if plugin_id in cls._plugins:
raise ValueError(f"Channel plugin '{plugin_id}' is already registered")
cls._plugins[plugin_id] = plugin
logger.info("Registered channel plugin: %s", plugin_id)
return plugin
@classmethod
def get(cls, plugin_id: str) -> Any:
return cls._plugins.get(plugin_id)
@classmethod
def list_plugins(cls) -> list[str]:
return cls.list_ids()
@classmethod
def list_all(cls) -> list[Any]:
return sorted(
cls._plugins.values(),
key=lambda p: (getattr(p, "order", 0), getattr(p, "id", "")),
)
@classmethod
def list_ids(cls) -> list[str]:
return sorted(cls._plugins.keys())
@classmethod
def all(cls) -> list[Any]:
return cls.list_all()
@classmethod
def unregister(cls, plugin_id: str) -> Any | None:
with cls._ensure_lock():
plugin = cls._plugins.pop(plugin_id, None)
if plugin is not None:
logger.info("Unregistered channel plugin: %s", plugin_id)
return plugin
@classmethod
def retire(cls, plugin_id: str) -> Any | None:
return cls.unregister(plugin_id)
@classmethod
def clear(cls) -> None:
with cls._ensure_lock():
cls._plugins.clear()
@classmethod
def has(cls, plugin_id: str) -> bool:
return plugin_id in cls._plugins
@classmethod
def is_registered(cls, plugin_id: str) -> bool:
return cls.has(plugin_id)

View File

@ -0,0 +1,552 @@
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))