DOCUMENTATION · 权限模型
权限模型
引擎贯彻一条原则:插件不能直接篡改主引擎状态,必须通过受权限校验的 API。 本文列出全部可用权限、含义、声明方式、未授权行为、校验实现位置与安全建议。
权限列表
权限名是字符串字面量,定义在 src/plugins/types.ts 的 ALL_PERMISSIONS
(同时导出运行时数组与 Permission 联合类型)。
| 权限 | 含义 | 对应 ctx API | 未授权时 |
|---|---|---|---|
state:read | 读取状态(变量 / 标志 / 物品 / 角色) | ctx.state.get/has/getFlag/itemCount/getActor/snapshot/watch | 抛 PluginPermissionError |
state:write | 写入状态 | ctx.state.set/setFlag/addItem/removeItem/setActorField/batch/patch | 抛 PluginPermissionError |
storage:read | 读取插件私有存储 | ctx.storage.get / ctx.storage.keys | 抛 PluginPermissionError |
storage:write | 写入 / 删除插件私有存储 | ctx.storage.set / ctx.storage.remove | 抛 PluginPermissionError |
assets:load | 加载资产(图片 / 音频 / JSON / 文本)、播放声音与控制全局音量 | ctx.assets.load/sound/image/play/setVolume/setMuted | 抛 PluginPermissionError |
audio:mix | 改音频分组(BGM / 音效 / 人声)的音量、静音、并发上限与暂停 | ctx.assets.groupVolume/setGroupVolume/setGroupMuted/configureGroup/stopGroup/setSuspended | 抛 PluginPermissionError |
expressions:register | 给剧情表达式注册函数(ctx.expressions.register) | 函数必须是纯的(引擎没法自动强制);不许遮蔽内置;名字先到先得 | |
contracts:provide | 向其它插件提供契约("我提供 X") | 契约是耦合,允许耦合由 manifest 决定 | |
contracts:call | 调用其它插件提供的契约("我需要 X") | 同上;contracts.has/list 不需要权限(公开事实) | |
commands:register | 注册剧情指令 | ctx.commands.register | 抛 PluginPermissionError |
conditions:register | 注册条件 | ctx.conditions.register | 抛 PluginPermissionError |
effects:register | 注册效果处理器 | ctx.effects.register | 抛 PluginPermissionError |
nodes:register | 注册自定义剧情节点类型 | ctx.nodes.register | 抛 PluginPermissionError |
events:listen | 监听引擎事件 | ctx.events.on / ctx.events.once | 抛 PluginPermissionError |
events:emit | 广播事件 | ctx.events.emit | 抛 PluginPermissionError |
ui:register | 注册 UI 组件 | ctx.ui.register | 抛 PluginPermissionError |
story:read | 只读剧情信息(当前节点 / 是否结束) | ctx.story.currentNodeId / ctx.story.isEnded | 抛 PluginPermissionError |
story:control | 控制剧情流程(跳转 / 结束) | ctx.story.jumpTo / ctx.story.end | 抛 PluginPermissionError |
world:register | 注册世界规则(每 tick 计算什么)+ 控制节拍 | ctx.world.registerRule / pause / resume / timeScale / step | 抛 PluginPermissionError |
world:emit | 发布世界事件(供其它插件与剧情 observe 订阅) | ctx.world.publish | 抛 PluginPermissionError |
world:listen | 订阅世界事件(通常用于驱动 UI 刷新) | ctx.world.subscribe | 抛 PluginPermissionError |
ctx.story.currentNodeId()/ctx.story.isEnded()需要story:read(读),jumpTo/end需要story:control(控制)。持有story:control的插件视为同时可读 (向后兼容老插件)。
作用域限定权限(把一个权限收窄到某条路径)
权限名可以写成「基础权限 + 状态路径前缀」,例如:
"permissions": ["state:read", "state:write:var.arena"]state:write:var.arena 只允许写 var.arena 与它的后代(var.arena.hp),
不能写 var.other,也不能写 flag.*;前缀按点号分段匹配(var.arena2 不算命中)。
适合那种「只动自己那一块状态」的插件 —— 一个每秒写 8×8 矩阵 + 8 个角斗士的插件,
不必为此拿到全量 state:write。
两条边界,都是有意为之:
ctx.state.snapshot()与ctx.state.batch()会触达任意路径,只认未限定的基础权限;ctx.state.patch({...})会逐个键检查(所以patch不会成为绕过作用域的后门)。
权限判定也可以自己用:isPermission() / splitScopedPermission() / permissionAllowsPath()
(都从 src/plugins/types.ts 导出)。
只读 vs 需权限
ctx.pluginId/ctx.pluginName/ctx.apiVersion/ctx.permissions/ctx.logger/ctx.hasPermission(): 无需任何权限。ctx.state的读方法需要state:read,写方法需要state:write(读与写是分开的)。
WASM 插件的权限对应关系
WASM 插件没有 ctx 对象,它通过宿主 imports 访问宿主能力,权限校验发生在宿主侧:
| 宿主 import | 需要的权限 | 未授权时返回值 |
|---|---|---|
host_log | 无 | 照常写日志 |
host_emit | events:emit | -100(HOST_REJECTED) |
host_get_state | state:read | 0(失败) |
host_set_state | state:write | -100(HOST_REJECTED) |
WASM 插件若要注册命令,仍需要在 manifest 声明 commands:register:
宿主在 init 阶段按插件返回的 commands 列表注册代理,而注册动作本身需要该权限。
关于世界模拟器权限
世界模拟器的三条权限对应 ctx.world 的三种能力(见 PLUGIN_JS.md):
| 权限 | 能力 | 典型用途 |
|---|---|---|
world:register | 注册「每拍执行」的世界规则、控制世界节拍 | JS 插件把「这一拍要算什么」登记给 WorldLoop,每 tick 自动调用;也可用 ctx.world.pause/resume/timeScale/step 改节奏(能驱动世界,才能改它的节奏) |
world:emit | 发布世界事件 | 插件把世界变化广播出去,供剧情 observe / 其它插件 / WASM 订阅 |
world:listen | 订阅世界事件 | 插件实时响应世界(驱动 UI 刷新、告警等) |
WASM 插件没有
ctx.world:它通过现有 imports(host_emit=events:emit、host_set_state=state:write、host_get_state=state:read、事件代理 =events:listen)参与世界模拟, 典型做法是声明监听world:tick,每拍推进自己的状态机,再用host_emit发布高层世界事件。
关于存储权限
ctx.storage 是插件私有的持久存储(按插件 id 命名空间隔离的 localStorage)。
与 ctx.state 的分工:状态随存档走、随剧情重置清空;存储跨剧情 / 跨局保留。
| 权限 | 能力 | 典型用途 |
|---|---|---|
storage:read | 读取本插件的存储 | 读取最高纪录 / 设置 / 统计 |
storage:write | 写入、删除本插件的存储 | 保存最高纪录 / 设置 |
assets:load | 加载图片 / 音频 / JSON(按 kind+url 去重、引用计数归零才释放)、播放声音、设置全局音量与静音 | 绘制贴图、播放音效、读关卡数据;宿主可用 runtime.assets.setMuted() 做全局静音按钮 |
audio:mix | 分组音量与静音、并发上限、按组停止、切后台暂停 | 影响的是"玩家整体听到什么"(不只你自己那份),所以要单独申请:音乐关小、音效留着;或做设置界面 |
expressions:register | 给剧情表达式注册函数(ctx.expressions.register) | 函数必须是纯的(引擎没法自动强制);不许遮蔽内置;名字先到先得 |
contracts:provide | 向其它插件提供契约(ctx.contracts.provide) | 契约就是耦合:允许耦合由 manifest 说了算;名字先到先得,冲突报错 |
contracts:call | 调用别人提供的契约(ctx.contracts.call) | 缺依赖在启动时就报(不启动 + error),不做成运行时"没反应";has/list 不需权限 |
非浏览器环境(单测、SSR)没有
localStorage时ctx.storage.available为false:get返回兜底值、set返回false,不会抛错。
在 manifest 中声明
permissions 是字符串数组,可缺省(等价于空数组,插件将无法访问任何受控能力)。
{
"id": "com.example.hello",
"name": "Hello Plugin",
"version": "1.0.0",
"type": "js",
"entry": "index.js",
"apiVersion": 1,
"permissions": [
"state:read",
"state:write",
"commands:register",
"nodes:register",
"events:listen",
"events:emit",
"ui:register",
"world:register",
"world:emit",
"world:listen"
]
}声明规则(由 PluginManager.validateManifest 强制):
- 必须是数组;每项必须是字符串且属于
ALL_PERMISSIONS,否则抛PluginManifestInvalid:未知权限 "xxx",可用权限:state:read, state:write, ...。 - 重复项会被自动去重(按首次出现顺序保留)。
- 缺省
permissions视为[],不报错。
最小权限原则:只声明真正用到的权限。例如纯只读的状态展示插件只需要 state:read。
未授权时的行为
JS 插件
调用未授权 API 时:
- 宿主记录一条警告日志(带插件 id),形如
[plugin:com.example.hello] 插件尝试执行 "state.set(flag.x)",但缺少权限 state:write,已拒绝。 - 抛出
PluginPermissionError(code = PLUGIN_PERMISSION_DENIED,携带pluginId与permission)。
这个异常会沿调用栈向上冒泡:
- 若发生在
init阶段 → 插件加载失败,状态置为error,已注册能力被回滚。 - 若发生在 命令 / 节点 / 条件 / 效果调用中 → 被
PluginManager.invoke捕获, 计入失败次数(累计到阈值会熔断),并转化为PluginError抛给引擎; 引擎把story:error广播出去并在 UI 显示错误横幅,但剧情主循环之外的系统不受影响。 - 若发生在 事件监听器 / UI 组件渲染中 → 由
EventBus或Renderer就地捕获, 只记录日志,不影响其它监听器与剧情。
WASM 插件
宿主 import 不会抛异常(WASM 侧无法处理 JS 异常),而是返回错误码:
host_emit/host_set_state未授权返回-100;host_get_state未授权返回0。
Rust 侧应当检查返回值并自行处理(示例插件在 host_get_state 返回 0 时会返回 None,命令逻辑继续)。
权限校验实现位置
| 环节 | 文件与函数 |
|---|---|
| 权限名合法性校验 | src/plugins/PluginManager.ts → validateManifest() |
| 授权集合构造 | src/plugins/PluginManager.ts → load() 里 new Set(manifest.permissions ?? []) |
| JS 侧状态权限校验 | src/plugins/PluginContext.ts → createStateApi() 的 requirePermission |
| JS 侧事件权限校验 | src/plugins/PluginContext.ts → createEventApi() 的 requirePermission |
| JS 侧世界权限校验 | src/plugins/PluginContext.ts → createWorldApi() 的 requirePermission |
| JS 侧注册权限校验 | src/plugins/PluginContext.ts → makeRegisterApi()、UI 注册分支 |
| JS 侧流程控制校验 | src/plugins/PluginContext.ts → storyApi.jumpTo / storyApi.end |
| 命令处理器里的权限 | src/plugins/PluginManager.ts → buildApi()(复用受校验的 API) |
| WASM 宿主 import 权限 | src/plugins/WasmPluginLoader.ts → env.host_emit / host_get_state / host_set_state |
| 能力回收(权限闭环) | src/plugins/PluginContext.ts → cleanup();PluginManager.teardown() |
为什么
buildApi()很重要:插件处理器的第二个参数api里的state/events不是原始的StateStore/EventBus,而是用该插件权限重新构造的受校验 API。 这样即使插件在命令处理器里调用api.state.set(...),也仍然要过state:write校验。 引擎内置能力(owner 为__engine)没有插件上下文,按全权限处理。
安全建议
面向引擎维护者
- 默认拒绝:新增宿主能力时,默认要求权限,只有确实无害的(如日志)才免权限。
- 读写分离:读与写使用不同权限,避免只读插件意外改状态。
- 不要暴露内部对象:任何返回给插件的对象都应是「封装后的 API」,绝不返回
StateStore/EventBus/StoryEngine的原始引用。当前实现只在PluginContext与buildApi里构造 API。 - 校验与构造同源:状态 / 事件 API 统一定义在
PluginContext.ts(createStateApi/createEventApi), 避免「上下文里校验、命令处理器里不校验」的漏洞。新增路径请复用这两个工厂。 - 卸载即回收:所有注册都必须带
owner,cleanup()必须覆盖全部 5 张注册表 + 事件总线- UI 组件卸载,防止插件「复活」。
- UI 容器隔离:插件组件只能操作宿主传入的容器;组件卸载时必须调用插件返回的
dispose并移除 DOM。 - WASM 边界防御:magic 校验、必需导出校验、
assertRange指针越界校验、dealloc内存归还缺一不可。
面向插件作者
- 最小权限:只声明真正需要的权限;申请了
state:write意味着你能改所有剧本状态。 - 不要试图绕过:你拿到的一切都是受检 API,绕过尝试只会让插件进入
error或被熔断。 - 用
effects而不是直接写状态(可选):命令返回effects由引擎统一落地, 更容易被剧本审计,也让权限边界更清晰(效果落地走的是引擎路径)。 - 处理异常:
init里抛错会导致插件加载失败;回调里抛错会累积失败次数,请自行try/catch。 - 清理资源:在
dispose里释放定时器、DOM、外部监听;引擎只会兜底回收它自己登记的资源。 - 日志带上下文:
ctx.logger已自动带plugin:<id>前缀,直接用它即可。
当前权限模型的边界(重要)
- 权限是能力级别而非数据级别:拿到
state:write就能写任意状态路径。 如需更细粒度控制,应在引擎侧扩展「路径白名单」式的校验(当前未实现)。 - JS 插件与宿主同进程运行,权限模型是防误用与防越界的约束,不是沙箱。 不可信代码必须放在 WASM 里,或放到独立 Worker / iframe 中执行。
- WASM 插件同样与宿主共享主线程,其内存虽被线性内存隔离,但错误码/指针仍需宿主逐一防御。
- 不支持运行时动态提权:权限在
load()时确定,变更需重新加载插件。
关于 story:control
只有 story:control 权限能改变剧情流程:
ctx.story.jumpTo('forest') // 需要 story:control
ctx.story.end('提前结束') // 需要 story:control
ctx.story.currentNodeId() // 只读,无需权限
ctx.story.isEnded() // 只读,无需权限注意:插件通过 effects 或命令返回值里的 next / stop 也能间接影响流程,
那是引擎主动采纳插件建议的结果(引擎在 handlePlugin 里处理),与直接调用 story.jumpTo 是两条路径。
若希望插件连「建议跳转」都不允许,可在引擎侧过滤命令结果中的 next / stop 字段。