DOCUMENTATION · 权限模型

权限模型

引擎贯彻一条原则:插件不能直接篡改主引擎状态,必须通过受权限校验的 API。 本文列出全部可用权限、含义、声明方式、未授权行为、校验实现位置与安全建议。

同步自 docs/PERMISSIONS.md(255 行) · commit b2b4124 · 读源文件 ↗


权限列表

权限名是字符串字面量,定义在 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

作用域限定权限(把一个权限收窄到某条路径)

权限名可以写成「基础权限 + 状态路径前缀」,例如:

"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。

两条边界,都是有意为之:

权限判定也可以自己用:isPermission() / splitScopedPermission() / permissionAllowsPath() (都从 src/plugins/types.ts 导出)。

只读 vs 需权限

WASM 插件的权限对应关系

WASM 插件没有 ctx 对象,它通过宿主 imports 访问宿主能力,权限校验发生在宿主侧:

宿主 import需要的权限未授权时返回值
host_log无照常写日志
host_emitevents:emit-100(HOST_REJECTED)
host_get_statestate:read0(失败)
host_set_statestate: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 强制):

最小权限原则:只声明真正用到的权限。例如纯只读的状态展示插件只需要 state:read。


未授权时的行为

JS 插件

调用未授权 API 时:

  1. 宿主记录一条警告日志(带插件 id),形如 [plugin:com.example.hello] 插件尝试执行 "state.set(flag.x)",但缺少权限 state:write,已拒绝。
  2. 抛出 PluginPermissionError(code = PLUGIN_PERMISSION_DENIED,携带 pluginId 与 permission)。

这个异常会沿调用栈向上冒泡:

WASM 插件

宿主 import 不会抛异常(WASM 侧无法处理 JS 异常),而是返回错误码:

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)没有插件上下文,按全权限处理。


安全建议

面向引擎维护者

  1. 默认拒绝:新增宿主能力时,默认要求权限,只有确实无害的(如日志)才免权限。
  2. 读写分离:读与写使用不同权限,避免只读插件意外改状态。
  3. 不要暴露内部对象:任何返回给插件的对象都应是「封装后的 API」,绝不返回 StateStore / EventBus / StoryEngine 的原始引用。当前实现只在 PluginContext 与 buildApi 里构造 API。
  4. 校验与构造同源:状态 / 事件 API 统一定义在 PluginContext.ts(createStateApi / createEventApi), 避免「上下文里校验、命令处理器里不校验」的漏洞。新增路径请复用这两个工厂。
  5. 卸载即回收:所有注册都必须带 owner,cleanup() 必须覆盖全部 5 张注册表 + 事件总线
    • UI 组件卸载,防止插件「复活」。
  6. UI 容器隔离:插件组件只能操作宿主传入的容器;组件卸载时必须调用插件返回的 dispose 并移除 DOM。
  7. WASM 边界防御:magic 校验、必需导出校验、assertRange 指针越界校验、dealloc 内存归还缺一不可。

面向插件作者

  1. 最小权限:只声明真正需要的权限;申请了 state:write 意味着你能改所有剧本状态。
  2. 不要试图绕过:你拿到的一切都是受检 API,绕过尝试只会让插件进入 error 或被熔断。
  3. 用 effects 而不是直接写状态(可选):命令返回 effects 由引擎统一落地, 更容易被剧本审计,也让权限边界更清晰(效果落地走的是引擎路径)。
  4. 处理异常:init 里抛错会导致插件加载失败;回调里抛错会累积失败次数,请自行 try/catch。
  5. 清理资源:在 dispose 里释放定时器、DOM、外部监听;引擎只会兜底回收它自己登记的资源。
  6. 日志带上下文:ctx.logger 已自动带 plugin:<id> 前缀,直接用它即可。

当前权限模型的边界(重要)


关于 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 字段。