policy 与写边
四档模型回答「这是什么」。本页回答两个后续问题:它能改什么,以及改完之后如何撤销。
前者由写边(write edge)与被写目标的 policy 决定,后者由注册时挂上的 effect 决定。两者都不依赖扩展代码的自觉:未声明的写边在调用时就会失败,卸载时叠加层自动摘除。
读边与写边
| 行为 | 是否改变既有节点的可观测行为 | 归类 |
|---|---|---|
inject 他人服务、订阅通知型插槽 | 否 | 读边 |
| 向实现位新增一个条目 | 否 | 纯新增,不构成写边 |
在改写型插槽上挂 before / after / replace | 是 | 写边 |
intercept 目标服务的 resolved config | 是 | 写边 |
在 isolate 域内 provide,替换正在使用的 provider | 是 | 写边 |
| 数据包写入 registry 条目 | 不适用(无代码) | 内容覆盖,不计入写边 |
写边的定义只有一句:改变了自身子树之外既有节点的可观测行为。读边不改变任何人,所以仍然属于插件;写边改变了既有节点,所以属于 Mod。
代码单元必须在清单的 writes 中声明全部写边:
| 字段 | 对应行为 |
|---|---|
writes.hooks | 改写型插槽及其阶段 before / after / replace |
writes.intercepts | 要 intercept 的服务名 |
writes.isolateProvides | 在 isolate 域内要 provide 的服务名 |
writes 非空即 Mod,为空即插件。装载器据此计算档位,与 kind 不符时以 GUARD_CLASSIFICATION_MISMATCH 拒绝。
policy 由谁声明
policy 是被写目标自身声明的、静态的开放度,不是发起者的自证,也不是装载时的协商结果。
| 问题 | 规则 |
|---|---|
| 谁声明? | 被写目标所属的大插件或条目本身 |
| 何时确定? | 契约与条目定义时,静态确定 |
| 粒度是什么? | 最小可写单元:一个插槽、一个服务、一个实现位、一个 registry 条目、一个资产路径 |
| 为什么不是发起者声明? | 外部性由被写方承担,开放与否必须由被写方决定 |
这条归属规则解决的是「谁承担后果」:如果由发起者自报,任何扩展都可以声称目标已授权,policy 就失去意义。
三种 policy
| policy | 语义 | 装载结果 | 典型目标 |
|---|---|---|---|
closed | 目标独占 | 拒绝,保留原值;GUARD_POLICY_CLOSED | 内核查询服务、能力服务、审计报告、敏感存储 |
public | 预授权覆盖 | 放行;普通 Mod | 多数实现位、开放给内容扩展的机制插槽 |
trusted | 允许,但需要更高信任 | 放行;特殊 Mod;需签名清单内的发布者,或逐条用户同意;同意会写入信任存储留痕,hook 生效/摘除的审计当前未实现 | 剧本流程、舞台合成、存档后端等关键机制 |
几条容易忽略的边界:
- 机制插槽不存在
closed档。 一个插槽若不允许外部改写,就不应声明为机制插槽。 - registry 条目只取
closed或public。 数据条目不能声明trusted。 - 覆盖既有条目时不得改变其 policy。 原条目是
closed就拒绝覆盖;试图在覆盖时改 policy 会被判为清单不合法。 trusted的提权不是自动的。 发布者不在清单内、密钥冲突、未签名且允许进入批准流程的单元(release 下未签名代码单元直接拒绝),或需要额外能力同意时,装载会停在待裁决状态,由用户逐条确认。
Mod 的权力白名单
Mod 只能通过三条入口产生写边:
| 入口 | 作用 | 约束 |
|---|---|---|
intercept(target, config) | 改写目标服务的 resolved config | 服务名须在 writes.intercepts 内,且目标非 closed |
hook: <slot>@before|@after|@replace | 改写机制插槽的结果 | 仅限改写型插槽;policy 为 public 或 trusted;replace 同槽唯一 |
isolate 域内 provide | 替换正在被使用的 provider | 服务名须在 writes.isolateProvides 内;域外 provide 一律报错 |
注册实现位不属于白名单三件套。 它是 Plugin 的落点:新增一个条目,与既有实现并存,因此不需要写边。选择由用户通过框架或外壳控制面完成;插件与 Mod 均无权调用 select 等入口代用户切换。只有当扩展卸载当前 provider、换成自己的实现时,才越过了这条线,升为 Mod。
以下行为一律禁止:
- monkey patch、修改对象原型、劫持模块加载;
ctx.set他人服务,或直接操作他人 registry 中的 fiber;- 写入
closed目标; - 任何绕开 effect 机制的注册。
禁止的理由不是风格偏好,而是撤销能力:白名单三件套注册的全部是叠加层,卸载时摘掉叠加层,目标自动回到之前的值。绕开 effect 的改动没有对应的撤销记录,卸载后无法恢复。
卸载时发生什么
| 写入类型 | 卸载时的动作 | 结果 |
|---|---|---|
| 机制插槽 hook | 从 before / after 列表移除,或清空 replace 位 | 后续调用不再经过该 hook |
intercept 配置视图 | 撤销该单元作用域内的配置覆盖 | 目标服务读回原配置 |
isolate 域内 provide | 撤销该作用域的 provider 替换 | 依赖者按 Cordis 的级联规则重建 |
| 实现位条目 | 从实现位删除该条目 | 按声明回落默认实现,或该位空缺 |
| registry 层 | 移除该数据包的整层并重算 | 后挂层的条目重新计算;原值恢复 |
| 资源包来源层 | 从资产来源栈摘下该层 | 资产解析回到上一层 |
这些动作由 Cordis 的 effect 树承担,不需要扩展作者手写清理代码。因此「卸载后留下半截状态」不是正常路径;如果真的留下,说明写入绕过了 effect。
装载后核验检查什么
卸载时的撤销回答「移除后怎样」,装载后核验回答「装载时说的和做的是否一致」。两者不是同一件事。
| 机制 | 发生时机 | 检查内容 | 失败结果 |
|---|---|---|---|
| 调用时裁剪 | 每次写边调用 | 该写边是否在清单中声明 | 未声明立即抛出 SlotError |
| 装载后核验 | 单元挂载之后 | 声明过的写边是否全部发生 | LOADER_POST_CHECK_FAILED,卸载该单元 |
注意核验是双向的:
- 未声明却调用:在调用点直接失败,不会静默生效。
- 声明了却没发生:同样判为不符,装载后卸载。声明因此是承诺,不是许可清单。
代码单元拿到的 ctx 是按清单裁剪过的:intercept 的服务名、isolate 域内 provide 的服务名都受 writes 限制;isolate 域之外的 provide 直接报错。通过 ctx.root 等途径绕开的调用不在这层裁剪内,这也是下一节要说明的边界。
这不是安全边界
政策与核验都是流程约束,不是技术隔离。
- 插件与 Mod 都是与框架同 realm 的受审代码,能拿到
ctx,能访问同一份 JavaScript 运行时。 isolate只是服务解析的命名空间隔离,不是沙箱。- 卸载后的恢复保证,依赖扩展遵守白名单。违反只在卸载后可观测,这正是孤儿标记与清理要处理的问题。
- 敏感能力由宿主侧持有;不
provide就取不到。任何存放在 JS 侧的安全状态都可能被扩展代码篡改。
因此正确的心智模型是:policy 让守规矩的扩展有明确边界,让越界行为可被发现;它不负责阻止恶意代码。 生态质量最终由发布与审阅流程决定。
常见误解
常见误解
- 「
closed是一种加密或沙箱。」closed只表示写入会被拒绝,不提供任何运行时隔离。 - 「
public的目标谁都能改,所以它是公共 API。」public只表示预授权覆盖;具体入口仍受白名单与声明约束。 - 「挂了
public机制插槽就还是插件。」 改写型插槽一律是 Mod,与 policy 档位无关。 - 「注册实现位也是写边。」 新增条目与切换选择不改变既有节点行为;替换在用 provider 才是写边。
- 「声明了写边就一定成功。」 目标为
closed、未绑定契约版本、或用户拒绝时仍会被拒绝。 - 「卸载后恢复,说明这套机制能防恶意扩展。」 它只在扩展遵守白名单时成立,不是技术安全边界。