状态:v1 定稿(对应
docs/plugin-swarm-execution.md§4-S10 与docs/plugin-api.md§3) 实现:angelus/plugins/security.py(权限门禁 + integrity checksum + 审计日志) 本文档描述插件系统的风险模型、v1 的安全边界与 v2 子进程隔离路线。任何安全相关决策 的变更(新增权限 action、放宽白名单、改变 checksum 语义)必须先更新本文档再改代码。
插件系统引入的资产与信任假设:
| 资产 | 说明 | 保护目标 |
|---|---|---|
| 主进程能力 | shell 执行、网络、文件系统、环境变量、connector 凭据 | 未被授予的插件不得触碰 |
| 用户数据 | 会话、workspace 文件、connector 元数据 | 只读/只写需显式授权 |
| 插件自身 | 安装目录下的 manifest 与入口代码 | 安装后被篡改不得被加载 |
| 审计日志 | angelus.plugins.security logger |
拒绝事件必须可追溯 |
信任边界(v1,D1 决策):
- 同进程 import(D1 默认):插件代码以
angelus_plugins.<name>命名空间 import 进主进程, 与主进程同特权级。插件一旦被加载,其 Python 代码能访问主进程能访问的一切; - 因此 v1 的防线不是运行时沙箱,而是权限门禁——插件只能通过
PluginRuntime提供的 注册面暴露扩展点,而扩展点背后的宿主能力(工具、钩子、路由、连接器)在真正执行前必须 经过check_permission核对plugins.json的permissions_granted; - 不在白名单内的能力一律拒绝并记日志,绝不静默放行(fail closed);
- 未启用(disabled)的插件不 import、不执行(S3
load_all只加载 registry 中 enabled 的插件, S10 在运行时门禁中再次确认enabled is True,双保险)。
MVP 阶段与内建工具(shell/swarm/knowledge 等)同权,实现简单、调试直观;子进程隔离 (真正的沙箱)成本高,列为 v2 路线(§8)。
同进程 import 下权限边界不能依赖 Python 层面的强制(插件代码可 import os 后自行
subprocess.run),边界成立依赖三个正交机制:
- 能力注册面收窄(S3/S4–S7):插件只能通过
PluginRuntime.register_*在setup()内注册扩展;bridge 层(bridge_tools.py/bridge_hooks.py/bridge_routes.py/bridge_connectors.py)是宿主能力与插件之间的唯一通道; - 权限门禁前置于能力执行(S10):bridge 层在把插件 handler 接入宿主能力(shell 工具、
钩子总线、静态文件、connector 读取)之前调用
check_permission;未授权则拒绝并记日志; - 启用门(S3+S10):registry 未启用或启用时未授予权限的插件,其代码根本不进入执行路径。
| 风险 | 说明 | v1 缓解 | v2 缓解(§8) |
|---|---|---|---|
| 恶意插件代码 | 插件 setup/handler 内可任意 import、exec、open |
权限门禁 + 用户 install 时逐项确认权限;代码即信任 | 子进程沙箱 + 系统调用过滤 |
| 权限提权 | 插件利用宿主 bug 绕过门禁 | 门禁为唯一入口;审计日志留痕 | 更小攻击面 + seccomp |
| 供应链篡改 | 安装后插件被改 | checksum 复核(§6) | 签名/远程证明 |
| 密钥窃取 | 插件读取 connector 密钥 | _public_connector 脱敏 + 只读路径(§5) |
密钥永不进子进程 |
结论:v1 的权限边界是"信任插件作者 + 门禁拦截误用/越权调用",不是"隔离不可信代码"。 插件安装即信任(用户显式确认权限清单),这一立场写入 v1 文档与 CLI 交互文案。
| action | 含义 | 典型 scope(1–512 字符) |
|---|---|---|
shell |
执行本地 shell 命令 | 命令/目录模式 |
network |
发起网络连接 | 域名/端口 |
fs.read |
读取文件 | 路径前缀 |
fs.write |
写入文件 | 路径前缀 |
env |
读取环境变量 | 变量名 |
http |
HTTP 请求 | URL 模式 |
connector.read |
读取连接器元数据(不含密钥) | provider 名 |
connector.write |
写入/更新连接器 | provider 名 |
event.subscribe |
订阅 agent 事件 | 事件名(白名单内) |
- 声明(
manifest.permissions,permission 对象数组)= 插件请求的权限; - 授予(
plugins.json[].permissions_granted,"action:scope"字符串数组)= 用户批准的权限; - 运行时的唯一判据是授予。插件声明了
shell但未被授予 ⇒ 调用被拒并记日志; 插件声明之外、被授予的权限(如 install 时勾选)同样有效——授予是权威; - install/enable 流程(S9)以
declared_permissions(manifest)生成确认清单,用户逐项批准后 才写入permissions_granted;-y只跳过交互确认,不跳过门禁。
check_permission(plugin_id, action, scope, registry=None) -> bool
action不在 9 枚举 ⇒ 拒绝(error 级日志,fail closed);scope非字符串或长度不在 1..512 ⇒ 拒绝;- registry 中找不到插件记录(或 registry 读失败)⇒ 拒绝(
reason=not-installed/REGISTRY_LOOKUP_FAILED); - 插件未启用(
enabled is not True)⇒ 拒绝(reason=plugin-disabled); permissions_granted不含精确的"action:scope"⇒ 拒绝(reason=not-granted)。
registry 参数可注入(默认 angelus.plugin_registry),bridge/CLI/测试均可传入替代实现;
require_permission 提供异常语义(deny 时抛 PermissionError)。
插件可订阅的事件名(点命名,见 docs/plugin-api.md §5):
agent.started、agent.stopped、tool.before、tool.after、session.created
- 白名单由 S3
angelus/plugins/base.py::HOOK_EVENTS定义,bridge(S5)映射到内部冒号事件; - 白名单外事件注册被拒绝(
register_hook拒绝 + 日志); - 钩子同步调用、单个钩子异常被隔离(不击穿 agent 主流程);
event.subscribe权限的 scope 应为白名单内事件名,由门禁与白名单双重校验。
- connector 凭据走既有 RSA-OAEP 加密存储(
angelus/connectors.py),解密只发生在服务端 发起 run 的那一刻(_resolve_connector_key); - 插件只能注册 provider factory(
register_connector,S7),不能读取已存密钥; - 插件可读取的 connector 元数据(
connector.read授权后)一律经过_public_connector脱敏: 仅返回id/name/provider/model/api_url/has_api_key,任何密钥字段不进入该视图;security.redact_connector(record)封装该边界(本地兜底:剥离名称含 key/secret/token/password/credential 的字段); - 规则:插件拿到的 connector 信息中
has_api_key只回答"有没有",永远不回答"是什么"。
- 格式
^sha256:[0-9a-f]{64}$(manifest.checksum与plugins.json[].checksum同构); - 安装期(S9 cli install /
compute_plugin_integrity)对插件的已安装载荷计算:sha256( canonical(manifest 去掉 checksum 字段) + "\n" + entry 文件字节 );- canonical =
json.dumps(sort_keys=True, separators=(",", ":"), ensure_ascii=False), 键序与空白不敏感、任何值的一字节变化都会改变哈希; checksum字段自身从哈希输入中排除,值才能自洽地写回 manifest(无循环);- 同时覆盖 manifest 与 entry ⇒ manifest 或 entry 任一改一字节即校验失败。
- canonical =
- 校验值同时写入
manifest.checksum(随插件目录自描述)与plugins.json[].checksum(registry 交叉核对)。
verify_plugin_integrity(plugin_dir, manifest, *, expected=None) -> (ok, errors)
由 manager load 在 import/setup 之前调用(S3 预留的 verify 钩子位,见
docs/plugin-swarm-execution.md §4-S3"加载前复核"),cli install/重装亦调用:
- manifest 无合法 checksum ⇒ 拒绝加载(
reason=missing-checksum)——未记录的插件不加载; - entry 无法在插件目录内解析为文件(含路径穿越尝试)⇒ 拒绝(
reason=entry-unresolvable); - 重算载荷哈希与期望值不一致(改一字节即不相等,
hmac.compare_digest常数时间比较) ⇒ 拒绝(reason=checksum-mismatch),错误级日志记录 expected/actual/entry; expected可注入:manager 若同时持有 registry 记录,可传入plugins.json[].checksum做第二重比对(防"manifest 被整体替换成新 checksum"式协同篡改)。
- checksum 是篡改检测(防意外损坏/部分篡改/被替换后重新计算可绕过),不是防恶意 攻击者的防伪——能重写 checksum 的写入者不在本模型威胁内(registry 0600、应用私有);
- 完整防伪/签名与远程证明列入 v2 路线(§8)。
/plugins/<name>/static/*只服务manifest.frontend.assets白名单内的文件; 白名单外的文件一律 404;- 服务前做 Path 规范化 + 白名单校验:
../、绝对路径、符号链接逃逸解析后必须仍落在 插件目录内(与security._resolve_entry_path同款resolve()+is_relative_to防线); - 未启用插件不挂载静态资源;CSP 只放开同源自域插件路径,不放宽
open等指令(S8); - 插件路由仅挂载于
/plugins/<name>/api前缀下,前缀隔离。
D1 的 v2 演进:把插件从"同进程 import + 门禁"升级为"独立子进程 + IPC + 系统级隔离"。
| 维度 | v2 目标 | 落地要点 |
|---|---|---|
| 进程模型 | 每插件一个子进程(或受限 worker 池) | multiprocessing/subprocess 起独立解释器;宿主与插件仅经 JSON-RPC/IPC 通道 |
| 命名空间 | 插件目录作为子进程的工作根 | 子进程 sys.path 只含插件目录 + 受控依赖 |
| 权限执行 | 门禁下沉到 IPC 边界 | 插件请求能力 ⇒ IPC 消息 ⇒ 宿主 check_permission 后代理执行;子进程无直接系统能力 |
| 系统隔离 | 依据授予权限施加 OS 级约束 | POSIX:setuid/降权用户 + seccomp-bpf(禁 execve/open 以外的 syscall)+ rlimit(CPU/内存/文件数/进程数);文件访问经宿主代理或 bind-mount 只读前缀 |
| 网络 | 按 network/http scope 过滤 |
子进程经宿主代理出网(或 eBPF/nftables 按 scope 限流) |
| 密钥 | 密钥永不进入子进程 | connector 解密仅宿主侧,按需把结果注入单次请求 |
| 生命周期 | 崩溃/超时/资源超限可杀 | 子进程 watchdog;插件 OOM 不拖垮宿主 |
| 兼容 | manifest/API 契约不变 | api_version 不因运行时模型变化而提升(§7 版本策略),新增 v2 加载器 |
v1→v2 迁移:verify_plugin_integrity 与权限门禁接口保持不变(manager load 的 verify 钩子位、
check_permission(plugin_id, action, scope, registry) 签名延续),桥接层改动集中在能力代理侧。
- logger:
angelus.plugins.security(logging.getLogger,get_logger()可取); - 行格式:
SECURITY <EVENT> k=v k=v ...(键排序,机器可解析、grep 友好); - 事件表:
| 事件 | 级别 | 触发 |
|---|---|---|
PERMISSION_DENIED |
WARNING/ERROR | 未授权调用被拒(含 reason=unknown-action/invalid-scope/not-installed/plugin-disabled/not-granted) |
PERMISSION_GRANTED |
INFO/DEBUG | 授权放行 / 新授予落盘 |
INTEGRITY_DENIED |
ERROR | checksum 复核失败(含 expected/actual/entry) |
INTEGRITY_OK |
INFO | 复核通过(加载前) |
REGISTRY_LOOKUP_FAILED / REGISTRY_UNUSABLE |
ERROR | registry 读取异常(门禁 fail closed) |
- 铁律:所有拒绝路径先记日志再返回 False,不允许静默放行/静默拒绝;
- 审计日志是取证与告警的基础:
reason字段区分"配置问题"(not-granted)与"攻击迹象" (checksum-mismatch / entry-unresolvable / traversal)。
| 层 | 调用方 | 调用 |
|---|---|---|
| S4 tools | bridge_tools.py(handler 执行前) |
check_permission(plugin, "shell"/"fs.read"/..., scope) |
| S5 hooks | bridge_hooks.py(注册/触发前) |
check_permission(plugin, "event.subscribe", event) + HOOK_EVENTS 白名单 |
| S6 routes/static | bridge_routes.py |
静态文件白名单 + Path 规范化;路由前缀隔离 |
| S7 connectors | bridge_connectors.py |
check_permission(plugin, "connector.read", provider);读路径 redact_connector |
| S3 manager | load() 于 import 前 |
verify_plugin_integrity(plugin_dir, manifest, expected=registry_checksum) |
| S9 cli | install/re-install | compute_plugin_integrity 写 checksum;install 前 verify_plugin_integrity 复核 |
- 未授权权限被拒并记日志(声明
shell但未 granted ⇒PERMISSION_DENIED reason=not-granted); - manifest/entry 改一字节 ⇒
INTEGRITY_DENIED reason=checksum-mismatch,拒绝加载; - 本文档覆盖风险模型(§1–§7)与 v2 子进程隔离路线(§8)。