Skip to content

Plugin Manifest 与 Loader

本课交付结果

你将交付 loadPlugin(root, name):读取本地 plugin.json,验证插件名、semver、权限数组和工具列表;工具名必须唯一,工具权限必须是插件声明子集,entry realpath 必须留在插件目录。

岗位问题

Plugin 不只是说明文字,它把代码入口交给 Runtime。若工具能临时请求 manifest 未声明的 write/network 权限,审批界面看到的能力清单就是假的;若 entry 能 ../ 逃逸,插件可加载任意本地模块。

前置检查

前置知识快照

先完成 Tool Contract:

bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 06

本课只验证与加载清单,不执行动态远程 import,也不访问网络。

第 31 课的 Skill 最坏会带来错误指令与上下文污染,Plugin 则把本地代码入口交给 Runtime,风险等级更高。加载一段 ESM 的顶层代码本身就可能读文件、启动进程或发网络请求,所以安全顺序必须是先把 manifest 当纯数据验证,确认能力边界与文件 provenance,再在隔离执行器中导入。任何“先 import 看看导出了什么”都已经越过验证边界。

Tool Contract 定义 name、description、inputSchema、permission 与 execute;Plugin manifest 定义哪个发布包被允许提供哪些工具。顶层 permissions 是插件能力上限,每个工具 permission 必须是其子集,运行时还要再与用户授权和会话策略求交集。清单声明不是自动授权,而是审批和沙箱可以信任的能力上界。

本地不等于可信。仓库依赖、个人插件、复制来的示例都可能过期或被篡改;路径逃逸、symlink、远程 URL 与顶层副作用都是供应链入口。本课先建立最小 loader,不下载、不执行远程模块、不自动安装依赖,让验证对象保持可枚举和可审计。

原理拆解

mermaid
flowchart TD
  A[校验 Plugin name] --> B[realpath 插件目录]
  B --> C[读取 manifest 纯数据]
  C --> D[校验 schema 与严格 semver]
  D --> E[建立插件权限集合]
  E --> F[逐工具校验唯一 name]
  F --> G[校验 entry 相对路径与 realpath]
  G --> H[校验工具权限为子集]
  H --> I[全部通过后返回验证清单]
  I --> J[隔离加载本地模块]

严格 name 定位插件目录,realpath 确认目录在 root。manifest version 必须是三段 semver。对每个工具依次验证 name 唯一、entry 相对且存在、真实入口仍在 pluginRoot、permissions 均出现在插件顶层声明。

代码实验

打开本课 Lab

bash
pnpm --dir bootcamps/coding-agent/labs/32-plugin-loader/starter test
pnpm --dir bootcamps/coding-agent/labs/32-plugin-loader/solution test

Starter 应声明 实现 Plugin 权限声明校验;Solution 应通过 5 项测试。

失败实现

ts
export async function loadUnsafe(root: string, name: string) {
  const manifest = JSON.parse(await readFile(join(root, name, "plugin.json"), "utf8"));
  return Promise.all(manifest.tools.map((tool: any) => import(tool.entry)));
}

失败实现没有 name、version、schema、路径或权限校验,entry 可以是绝对路径、点点斜线甚至远程协议;更关键的是它立即执行模块顶层代码。重复工具会按数组或注册表覆盖,审批界面看到的清单与真正执行能力没有可靠对应。

安全加载器先只构造经过验证的描述:

ts
for (const tool of manifest.tools) {
  if (names.has(tool.name)) throw new Error(`工具名称重复:${tool.name}`);
  names.add(tool.name);
  if (isAbsolute(tool.entry) || tool.entry.split(/[\\/]/).includes("..")) {
    throw new Error(`工具入口无效:${tool.name}`);
  }
  const entry = await realpath(join(pluginRoot, tool.entry));
  if (outside(pluginRoot, entry)) throw new Error(`工具入口超出目录:${tool.name}`);
  for (const permission of tool.permissions) {
    if (!declared.has(permission)) throw new Error(`使用未声明权限:${permission}`);
  }
}

关键实现讲解

权限比较用集合子集,而不是字符串包含。入口路径先拒绝绝对路径与 ..,再对存在文件 realpath;两层分别提供清晰错误和 symlink 防护。返回数组副本,避免调用方修改已验证清单。

Plugin name 同时用于目录定位与 manifest 身份,因此入口先用稳定 slug 校验,再要求 manifest.name === requestedName。若目录叫 trusted 而清单声明另一个公共名称,日志、审批和缓存会发生身份混淆。显示名称另设 displayName,路径身份始终使用受限 ASCII slug,不接收斜线、点、空格和 URL 编码。

root 和 plugin directory 都做 realpath,再用 relative 判断子路径。插件目录若是指向 root 外的 symlink,应在读 manifest 前拒绝。总项目进一步对路径每一级 lstat,拒绝任何软链接,即使最终 realpath 仍在 root;这使 provenance 更简单,防止链接目标在验证与 import 之间被替换。

manifest 文件有字节上限,并以 UTF-8 解析。JSON.parse 只证明语法,不证明 schema:数组、null、错误类型、重复权限和未知字段都要明确政策。教学 loader 手写判断关键字段,产品可用 JSON Schema 或 Zod,同时设对象深度与数组长度。错误指向字段路径,不回显整个清单或绝对目录。

schemaVersion 与 plugin version 解决不同问题。schemaVersion 表示 Loader 应如何解释 manifest;version 是插件发布的语义版本。旧 Loader 遇到不支持的新 schema 必须拒绝,不能忽略可能承载安全含义的新字段。插件 version 使用严格 major.minor.patch,可允许规范 prerelease,但不接受 latest、v1 或范围表达式。

严格 semver 让 Runtime 做兼容判断。major 改变工具 contract 或行为不兼容,minor 新增向后兼容工具,patch 修复实现。实际兼容还需声明 runtimeApiRange;manifest 版本本身不能说明宿主能否加载。锁文件记录插件 name、version、内容 hash 与宿主 API,避免每次启动自动漂移到新版本。

permissions 顶层数组必须由已知枚举组成、无重复并稳定排序,例如 read、write、execute、network。接受任意字符串会让拼错的 excute 进入审批,却在执行时产生另一套解释。Loader 验证语法和子集,Policy Engine 再根据 workspace、用户批准与当前动作决定实际许可。

子集判断使用 Set:对工具声明的每项权限,检查顶层集合包含。不能用字符串 contains,例如顶层 read-metadata 不应包含 read;也不能把所有工具权限求并集后自动扩大顶层声明,那会让清单失去审查意义。未声明权限在任何 module import 前失败,并报告工具名与权限名。

工具 name 是 Runtime 注册表的公共身份,必须符合 Tool Contract、在插件内唯一,也要在跨插件合并时处理冲突。教学 Lab 拒绝插件内重复;产品可以用 pluginName/toolName 命名空间避免不同插件碰撞,同时向模型展示稳定别名。绝不能让加载顺序决定后者覆盖前者。

工具 entry 是相对 pluginRoot 的本地文件。第一层拒绝绝对路径、点点段、反斜线变体和 URL;第二层对存在文件 realpath,确认仍在 pluginRoot;第三层 lstat 拒绝链接并检查普通文件。远程 https:data:node: 与裸包 specifier 都不属于本地插件入口,除非未来由独立可信解析器显式支持。

只检查 join(pluginRoot, entry).startsWith(pluginRoot) 不够,前缀相似目录、大小写和分隔符都会绕过。只拒绝字符串 ../ 也不够,Windows 反斜线、绝对盘符和 symlink 仍存在。lexical 检查提供早期清晰错误,realpath 提供实际对象边界,两层不是重复。

entry 存在并不代表 export 正确。验证清单全部通过后,隔离 loader 才 import,并验证 default export 拥有 definition 与 execute;definition.name、permission 必须与 manifest 或规范派生值一致。若 module 声称不同权限,立即卸载并报告供应链不一致。最好让 manifest 只列模块路径,模块 definition 作为第二份证据,二者交叉验证。

ts
it("rejects capability escalation before code execution", async () => {
  await writeManifest({
    permissions: ["read"],
    tools: [{ name: "inspect", entry: "tool.js", permissions: ["write"] }],
  });
  await expect(loadPlugin(root, "demo")).rejects.toThrow("未声明权限");
  expect(globalThis.pluginTopLevelExecuted).not.toBe(true);
});

为了真正证明“验证前未执行”,fixture module 可在顶层写一个哨兵文件或设置隔离进程标记,给清单制造权限错误,断言哨兵不存在。只看代码顺序容易在重构时回归,执行证据把安全声明固化成测试。不要让恶意 fixture 在主测试进程造成真实破坏,使用临时目录和子进程。

动态 import 有模块缓存。同一路径加载一次后,文件内容即使变化也可能复用旧 module;生产插件应按不可变版本/内容 hash 路径安装,不依赖热覆盖。开发热更新可以重启隔离 worker,避免通过查询参数绕缓存形成多份不可回收代码。一次 Agent run 锁定插件快照,不能中途换实现。

最安全的执行边界不是主进程 import,而是受限 worker 或子进程。Loader 在主进程验证 manifest 和 hash,启动低权限执行器,向其发送已批准工具调用;执行器文件系统、网络、环境与资源限额由沙箱控制。JavaScript import 没有天然 capability 安全,清单只描述意图,操作系统隔离才执行约束。

插件不能直接接收整个 Runtime 对象。Tool execute context 只含 workspaceRoot、安全 signal、经过授权的少量服务;秘密按调用需要短时注入,不放全局环境。返回值经过 ToolResult schema、大小限制和脱敏,异常转换为稳定错误,不把 stack、HOME 或模块绝对路径暴露给模型。

入口 hash 与插件目录 manifest hash 提供内容身份。只 hash plugin.json 会漏掉 tool.js 被替换;需要按排序路径计算允许文件树,排除 node_modules 等可重建内容或把依赖锁纳入。加载前后可复核 hash,安装时校验签名。Trace 记录 name/version/hash 和实际批准权限,不记录源码。

签名验证证明包来自持有私钥的发布者且未被改写,不证明代码安全。组织仍需 allowlist 发布者、静态扫描、人工审查、最小权限与沙箱。签名撤销、密钥轮换和离线缓存也要设计;个人本地开发插件可标记 unsigned-local,在生产策略中默认拒绝。

依赖管理是供应链重要部分。允许插件自行 npm install 会访问网络、执行 install script 并引入漂移。构建阶段使用锁文件、禁用不必要脚本、生成 SBOM 和漏洞扫描,运行时只加载已封装不可变产物。插件若需要 native 模块,还要声明平台与架构兼容,失败不能回退下载未知二进制。

插件卸载与 close 生命周期同样重要。工具可能持有 watcher、子进程或连接,执行器需要 dispose contract;Agent run 结束、插件禁用或进程取消时按结构化并发回收。主进程动态 import 很难真正卸载,更支持隔离 worker 方案。关闭超时后强制终止,并把资源泄漏记入 Trace。

错误分类至少包括 INVALID_NAME、MANIFEST_NOT_FOUND、INVALID_SCHEMA、INCOMPATIBLE_VERSION、DUPLICATE_TOOL、PERMISSION_ESCALATION、ENTRY_ESCAPE、MODULE_INVALID 和 SIGNATURE_INVALID。客户端依赖 code,中文 message 用于诊断。not found 与权限拒绝不能被捕获成“插件无工具”,否则系统会静默退化。

发现多个插件时,可以先读取所有 manifest 的纯 metadata 形成 catalog,用户选择后再做完整工具和模块验证,沿用第 31 课渐进披露。但任何进入审批界面的权限列表必须来自完整验证清单,不能只信摘要。catalog 缓存按 manifest hash 失效,执行缓存按整个插件内容 hash。

升级流程先在隔离环境同时加载旧新版本,验证工具 contract、权限没有意外扩大、回归套件通过,再切换锁文件。权限新增即使是 minor 版本也需要重新审批,不能继承旧批准。回滚恢复旧不可变包与 lock,不覆盖目录中的部分文件。

测试矩阵覆盖有效本地工具、invalid semver、重复工具、未声明权限、点点入口、远程 URL、外部 symlink、模块导出错误和返回清单不可变。真实课程 git-tools 插件还要执行一次只读 status,证明验证后适配 Tool Contract;测试 workspace 使用临时或课程插件 root,不触碰用户仓库变更。

运维观测记录加载成功/失败、验证耗时、插件 hash、工具数、声明/批准权限差异、执行器退出和拒绝原因。不能记录工具源码、调用秘密参数或完整绝对路径。突然出现新 network 权限、签名变化或同名 hash 改变应触发安全告警,不只是普通启动日志。

最终验收先加载 demo@1.2.3 与 inspect/read 工具,确认返回副本;依次把 version 改 latest、复制工具名、请求 write、entry 指到父目录,四次都在 module 哨兵触发前失败。再用合法 manifest 进入隔离执行器,验证 module definition 与清单一致并完成一次受限调用。

宿主兼容协商不能只看插件版本。manifest 应声明最低与最高 Runtime API 或能力特征,Loader 在 import 前比较;不兼容返回明确错误和升级方向。使用宽泛星号范围虽然方便,却把未来破坏性变化悄悄接受。兼容矩阵在插件持续集成中至少覆盖当前稳定宿主与下一候选版本。

工具 inputSchema 也是安全边界。模块导出的 JSON Schema 要限制对象字段、类型、大小和 additionalProperties;模型生成参数先由 Runtime 验证,再交 execute。Loader 可对 schema 自身设置深度和字节上限,拒绝外部 $ref 与会触发网络解析的引用。权限正确但参数无限制,仍可能造成资源滥用。

output 同样需要 contract。工具返回可判别 ToolResult,成功 value 经过序列化大小与秘密扫描,失败包含稳定 code 和安全 message。插件不能返回函数、循环对象或开放文件句柄;跨进程协议天然迫使数据可序列化,主进程 import 则要主动验证。超大 output 截断并提供 artifact 引用。

审批应绑定插件 hash、工具 name、permission、参数范围与 workspace,而不是只记“曾允许 demo”。插件升级、入口 hash 改变、权限新增或目标 workspace 变化都使旧批准失效。一次性批准消费后不自动延长;会话级批准有明确过期。Trace 记录批准 ID 与结果,不保存令牌秘密。

write 权限最好进一步缩成 allowedPaths,execute 限制 executable 与参数模板,network 限制 host 与协议。顶层字符串权限只是第一层 capability 类别;细粒度约束由 manifest policy 和调用审批共同决定。工具若请求 root 外路径,即使拥有 write 也被 Workspace Boundary 拒绝。

插件组合会出现依赖与冲突。manifest 可声明 requires plugin/version,但加载器先构建依赖图,检测缺失、版本不兼容与环,再按稳定拓扑顺序验证。不要让插件在顶层动态寻找另一个插件的全局对象。共享能力通过宿主 API 注入,版本边界与权限仍可见。

依赖图还要防混淆攻击。组织插件 git-tools 不应被公共同名包替代;来源 registry、publisher 与 name 共同形成 identity。锁文件保存解析后的唯一来源,安装器不根据网络搜索“最像的名字”。本地 root 只读取已经安装的目录,不自行补齐缺失依赖。

配置文件也可能含秘密。插件声明 config schema,用户值保存在独立 secret store,不写进 plugin directory 或 manifest;执行时只注入工具需要的键。错误消息不回显值,卸载时按政策保留或删除配置。插件代码无权枚举其他插件配置。

隔离 worker 的启动握手先发送宿主 API version、批准能力与内容 hash,插件回应工具清单;两边与 manifest 对比后才 ready。握手超时或差异立即终止 worker。每次调用带 requestId、deadline、AbortSignal 映射与输出限制,为下一课 MCP 生命周期提供相似模型。

崩溃策略要防无限重启。插件 worker 非零退出时拒绝所有 pending 调用、记录截断 stderr,并按固定上限退避重启;同一 hash 连续失败进入 quarantine,需要人工重新启用。不能因插件坏掉拖垮整个 Agent,也不能无声跳过让模型以为工具成功。

资源预算包括内存、CPU、子进程数、文件描述符、单次与总调用时间。权限说明“可做什么”,预算说明“最多做多少”。操作系统或容器负责硬限制,Runtime 在达到上限前拒绝新调用。超限错误与业务失败分开,评测才能判断插件质量还是资源配置。

发布 Plugin 时生成 manifest、内容树 hash、SBOM、签名与测试报告,所有产物一起进入不可变包。安装器先验证签名和 hash,再原子解压到版本目录,最后更新 lock;失败保持旧版本。不要在当前目录上逐文件覆盖,否则并发 Agent 可能读到一半新一半旧。

下线流程先阻止新 run 选择插件,等待现有调用完成或取消,关闭 worker,再移除 lock 引用;真正文件由后台回收。强删目录会让运行中 import 或资源读取失败。紧急撤销安全漏洞时可以立即取消,但仍记录受影响 run 与撤销理由。

最终可信链是:发布者签名证明包来源,Loader 证明 manifest 与路径合法,Policy 证明调用获批,沙箱证明执行不越界,Trace 证明实际发生什么,Evaluation 证明工具在基准任务中的效果。任何一环都不能由“插件是本地的”替代。

代码评审时应要求作者演示一个合法插件和四个恶意清单都不会在验证前触发顶层哨兵,并展示批准权限与实际沙箱能力的交集。只有清单、实现、运行策略和证据一致,Plugin 才是可治理扩展,而不是换了目录名的任意代码执行。

任何未能在执行前证明身份、边界和权限的入口,都不应进入工具注册表。

验证成功必须先于模块求值,运行授权必须晚于用户审批。

边界不可倒置。

运行与验证

bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 32
pnpm --filter @coding-agent/extensions test

把工具权限改为未声明 write 应失败;把 entry 改为 ../outside.js 也必须在任何代码执行前失败。

真实运行输出

text
✓ tests/lab.test.ts (5 tests)
Test Files  1 passed (1)
Tests       5 passed (5)

项目 extensions 测试还覆盖真实 git-tools、远程模块拒绝、软链接和模块权限交叉验证。测试报告应明确失败发生在验证阶段,而不是 import 后碰巧抛错。

常见失败与排查

故障案例 1

症状:相同配置在不同机器加载到不同实现,回滚时也无法确认目标版本。

根因:version 接受 latest、v1 或范围,安装目录可变且没有内容 hash。

定位:对比 manifest、锁文件、入口树 hash 与模块缓存路径,检查启动时是否自动更新。

修复:严格 semver、不可变版本目录和 lock;报告记录内容 hash,升级与权限变化走显式审批。

故障案例 2

症状:审批只显示 read,工具实际要求 write;或重复 name 的恶意工具覆盖合法工具。

根因:权限数组仅用于文档,没有子集验证;注册表按加载顺序赋值。

定位:对照顶层与每工具 permission,交换工具顺序,监控 import 哨兵是否在错误前执行。

修复:验证阶段 Set 子集与唯一 name,跨插件使用命名空间;运行时再与会话授权求交集。

故障案例 3

症状:entry 看似相对,却加载插件目录外模块或远程 URL,拒绝时顶层副作用已经发生。

根因:只做 lexical join,跟随 symlink,或者先 import 后验证 export。

定位:构造点点段、反斜线、外链、https 与顶层哨兵 fixture,记录真正 realpath 和执行时点。

修复:URL/绝对/点点早拒绝,realpath/lstat 复核普通文件;全部清单验证完成后才在隔离进程 import。

课后作业

加入 manifest schemaVersion、内容签名、API 兼容范围和 entry 文件哈希;设计隔离进程加载器,证明验证阶段不执行插件代码。

验收 Rubric

维度通过标准常见扣分
版本严格 semver任意标签
工具名称唯一、入口存在静默覆盖
权限工具是插件声明子集运行时扩权
边界入口 realpath 在插件内路径逃逸

总项目增量

总项目包:@coding-agent/extensions

总项目路径:packages/extensions/src/plugin-loader.ts

总项目验证命令:pnpm --filter @coding-agent/extensions test

Plugin 是本地能力包;下一课用 MCP 把能力隔离到独立 stdio 进程协议。

延伸阅读

  • Plugin ABI 与 semantic versioning。
  • Supply-chain signature 和 capability manifest。
  • 动态代码加载的隔离边界。

方案对比与工程取舍

静态编译内置工具最安全简单,发布时一起审查,却无法独立扩展;主进程动态 import 灵活但没有真正权限隔离;子进程插件增加协议和生命周期成本,却能落实文件、网络与资源边界。课程 Loader 先证明清单,再逐步迁移到隔离执行,符合风险递增顺序。

manifest 同时列工具 name/entry/permissions 冗余但审计直观;只列 module 路径再读取 definition 减少重复,却必须 import 后才知道能力。安全审批需要执行前信息,因此保留纯数据清单并与 module export 交叉验证更合理。重复不是坏事,两份证据不一致正是需要阻止的信号。

严格拒绝链接和远程模块限制生态便利,却建立可证明的本地供应链边界。将来若支持远程 MCP,不应放宽 Plugin entry,而是使用下一课的进程协议与独立信任配置。不同风险通道分开设计,比让一个万能 loader 识别所有 URI 更容易审计。

下一课衔接

Plugin 把本地代码作为能力包,仍需要 Runtime 自己承担模块隔离。下一课 MCP-lite 把工具搬到独立 stdio 进程:客户端用 JSON-RPC id 匹配乱序响应,并在 timeout、Abort、协议错误和进程退出时清空 pending 请求。扩展能力开始拥有明确的跨进程生命周期。

从零实现 Mini Code Agent Runtime