Insomnia 模板标签 QuickJS 沙箱:用官方示例插件验证沙箱隔离、模块白名单与权限门控
本文以仓库自带的 insomnia-plugin-sandbox-demo 示例插件为主线,完整讲解 Insomnia 模板标签 QuickJS 沙箱的验证方法:如何在开发模式下安装并切换"Run template tags in sandbox"开关、如何使用 6 个探针标签逐一验证沙箱运行环境、宿主桥接(host bridge)、清单门控的模块注册表(M1/M2/M3)、宿主能力授权(C1)与多文件插件(M4);并结合 模块注册表、沙箱引导脚本 与 宿主桥接实现 等源码,说明这些行为背后的实际隔离机制与报错契约,便于你在开发或审查社区插件时准确判断其运行边界。
这个示例插件是干什么的
examples/insomnia-plugin-sandbox-demo 是一个手工测试夹具(manual test fixture),用于验证"QuickJS 模板标签沙箱"(Milestone 2)这条执行路径。它由三部分构成:
- index.js:导出
templateTags数组,包含 6 个探针标签,每个标签专门验证沙箱的一个维度; - package.json:声明了
insomnia.permissions权限清单,是清单门控(manifest gating)的活体样本; - lib/greeting.js:被
index.js以相对路径require('./lib/greeting')引入的兄弟模块,用于验证多文件插件(M4)。
其 README 的定位说明:sandboxprobe 标签会报告自己执行在哪里,并演练一次异步宿主桥接往返。沙箱开关关闭时输出 hello | ran in: main-process | arch via bridge: <arch>;开启时输出 hello | ran in: sandbox | arch via bridge: <arch>。
安装与运行(开发模式)
按 README 中的步骤:
- 在仓库根目录运行应用:
npm run dev; - 打开 Preferences → Plugins,点击 Reveal Plugins Folder;
- 把
examples/insomnia-plugin-sandbox-demo整个文件夹复制到该插件目录; - 点击 Reload Plugins;
- Preferences → Scripting 中切换 Run template tags in sandbox (experimental) 开关;
- 在某个请求的 URL 或 Header 中插入
Sandbox Probe模板标签(或直接输入{% sandboxprobe 'hi' %}),观察预览结果随开关切换而变化。
从源码结构看,这个"experimental"开关对应 settings 定义 中的 pluginSandboxEnabled 字段,其注释明确写着"T1:把所有不可信(用户)插件面——模板标签、request/response 钩子、actions 以及加载期模块代码——全部放进 QuickJS-WASM 沙箱,用户插件默认拒绝(default-deny)"。也就是说该开关影响的不仅是模板标签,而是一整套用户插件执行面。
逐标签验证:6 个探针各测什么
sandboxprobe:运行位置 + 异步宿主桥接
sandboxprobe 标签 做了两件事:
// 判定运行环境:沙箱注入 INSOMNIA_TEMPLATE_SANDBOX 标记,主进程旧路径没有
const ranIn = typeof INSOMNIA_TEMPLATE_SANDBOX !== 'undefined' ? 'sandbox' : 'main-process';
// 演练一次异步宿主桥接:context.util.nodeOS -> pluginToMainAPI['nodeOS']
let arch = 'n/a';
try {
const os = await context.util.nodeOS();
arch = os.arch;
} catch (err) {
arch = 'bridge-error:' + err.message;
}
return `${label} | ran in: ${ranIn} | arch via bridge: ${arch}`;
两个值得注意的细节,README 都特意解释了:
-
为什么用专用标记而不是
process来区分环境:早期用typeof process判断即可,但从 M2 开始沙箱也提供了一个process桩(stub),两者都"有 process",因此需要专门的标记。源码印证了这一点——in-sandbox-bootstrap.ts 中以writable: false, configurable: false定义该全局量,插件代码无法伪造:Object.defineProperty(globalThis, "INSOMNIA_TEMPLATE_SANDBOX", { value: true, writable: false, configurable: false, enumerable: true }); -
arch via bridge证明异步往返链路完好:标签内await context.util.nodeOS()走的是__hostBridge('nodeOS', ...)。从 in-sandbox-bootstrap.ts 看,沙箱捕获宿主注入的__hostBridge函数后立即从globalThis删除(防止插件代码绕过context.*直接调用原始桥接),所有异步能力都收敛到唯一的__bridge(path, body)辅助函数;宿主侧则由 host-bridge.ts 按注入点(renderer / main / CLI)分派到真实的pluginToMainAPI处理器,结果以 JSON 字符串回传并恢复。另外 quickjs-runtime.ts 的注释解释了为何使用同步版 QuickJS WASM:异步化(asyncified)变体在用户代码await宿主调用时会损坏,因此宿主桥接返回 VM 原生 Promise,由编排器泵executePendingJobs()来结算。
requireprobe:清单门控的模块解析(M1)
requireprobe 标签 演示 require() 的两段式默认拒绝:
{% requireprobe 'path' %}→ 输出a/b(基线授权 + 精选注册表);{% requireprobe 'fs' %}或任意 npm 包 → 报错Module 'X' not permitted by manifest;- 已授权但 Insomnia 未打包的模块 → 报错
Module 'X' not available in sandbox。
源码中这两条错误信息是用户可见契约,被 in-sandbox-bootstrap.ts 的 __require 逐字抛出,并附带稳定判别码(SANDBOX_MODULE_NOT_PERMITTED / SANDBOX_MODULE_NOT_AVAILABLE)供宿主分支处理,单测与冒烟测试对文案逐字断言。解析逻辑与 module-registry.ts 文件头注释 一致:先查授权集(未授权→not permitted),再查注册表工厂(无工厂→not available)。当前注册表收录的模块为 path、crypto(基线,无需声明)、events(需声明),以及两个重量级(heavy: true、仅在插件声明时才打包进沙箱源码以避免每次渲染解析数百 KB)的官方捆绑 npm 库 uuid 与 ajv。
README 还说明了路线图:注册表覆盖范围在 M2/M3 逐步扩大;相对文件 require('./util') 随插件预打包(M4)提供——而本示例的 multifileprobe 标签已实际验证了这一点。
eventsprobe:清单声明式模块授权(C3)
eventsprobe 标签 演示"声明了就能用":本插件 package.json 声明了 insomnia.permissions.modules: ["events"],因此标签内可以:
const EventEmitter = require('events').EventEmitter;
const emitter = new EventEmitter();
let out = '';
emitter.on('ping', value => { out = value; });
emitter.emit('ping', 'events-ok');
return out; // 渲染为 events-ok
若某个插件未声明 events,同样一行 require('events') 就会得到 Module 'events' not permitted by manifest。从源码看,沙箱里的 events 并非 Node 原生内置,而是 module-registry.ts 中一段纯 JS 的极简 EventEmitter 重实现(覆盖 on/once/emit/removeListener 等常用面)——这正是"vetted safe equivalent"原则:require 永远解析到 Insomnia 提供的安全等价物,绝不是原始 Node 内置。
README 还提示:Preferences → Plugins 界面会显示每个插件已声明的权限(本插件显示 modules: events,以及下文的 storage)。
stdlibprobe:环境内置沙箱全局(M2)
stdlibprobe 标签 演示无门控、始终存在的沙箱内置全局:Buffer、URL/URLSearchParams、冻结的 process 桩、以及 Web-Crypto 的 crypto.getRandomValues/crypto.subtle。它们是纯 JS 或宿主支撑的安全等价物,渲染结果与旧的主进程路径一致:
{% stdlibprobe 'buffer' %}→Buffer.from('hi 👋').toString('base64');{% stdlibprobe 'url' %}→new URL('https://example.com:8443/p?a=1').host;{% stdlibprobe 'platform' %}→process.platform。
从源码结构看,除这些全局外,沙箱引导脚本还在最前面补齐了 QuickJS 缺失的 Web API(btoa/atob/TextEncoder/TextDecoder)与转发到宿主的 console(见 in-sandbox-bootstrap.ts)——这些同样不属于清单门控范围。
capabilityprobe:清单门控的宿主能力(C1)
capabilityprobe 标签 演示第二道授权轴——宿主能力:本插件声明了 insomnia.permissions.capabilities: ["storage"],因此可以完成 context.store 的 set/get 往返:
await context.store.setItem('demo_k', 'storage-ok');
return await context.store.getItem('demo_k'); // 渲染为 storage-ok
未声明 storage 的插件会收到 Capability 'storage' not granted — add it to insomnia.permissions.capabilities。基线能力(render、models.read、util、crypto)无需声明;network / storage / fs-read / app 必须声明;credentials 为第一方捆绑插件保留,社区插件即使声明也无法获得(它高于模板标签面的能力上限,详见 PERMISSIONS.md)。
从实现看,能力门控是双层防御:
- 宿主侧桥接门(C1):host-bridge.ts 的
filterByCapabilities包装每个桥接路径的处理器,路径未映射到任何能力→拒绝调用(fail-closed);映射的能力未被授权→抛出上述 "not granted" 错误。BRIDGE_PATH_CAPABILITIES是"每条沙箱可调用桥接路径 → 能力"的唯一映射,且有完整性单测保证引导脚本新增桥接路径而此处漏配时测试直接失败。 - 沙箱内上下文形状门(C2):__buildContext 先构建完整
context,再删除未授权分支(如if (!__hasCap("storage")) { delete ctx.store; })。因此未授权分支是undefined而非"存在但会拒绝的桩"——插件可以用if (context.network)做特性探测并优雅降级,且触碰未授权分支会在插件自己的属性访问处同步失败,而不是之后的异步 rejection。
vendoredprobe:官方审定的 npm 库(M3)
README 未单列的第六类探针 vendoredprobe 演示第三方库路径:本插件声明 modules: ["uuid"],标签内 require('uuid') 得到的是 Insomnia 精确钉版并预打包的库副本,执行 uuid.validate(uuid.v4()) 后渲染 uuid=ok / uuid=bad。
PERMISSIONS.md 对此有严格的供应链说明:每个 vetted 库来自隔离的、精确钉版的安装(src/templating/sandbox/vendored/pkg/),不是应用自身可能更新版本的 uuid/ajv 依赖,因此沙箱版本可独立审查且绝不随例行依赖升级漂移;不变量是"沙箱钉版永不领先应用自身解析版本",由 npm run sandbox:vendored:guardrail -w insomnia(CI 亦运行)强制。新增库通过 scripts/generate-sandbox-vendored.ts 谨慎添加;升级已审定库则优先使用 npm run sandbox:vendored:upgrade -w insomnia -- <lib>@<version>(它会校验安装版本与重新生成包内版本一致、检查上述护栏并先跑该库的回归测试)。
另外 PERMISSIONS.md 特别警告:在 run() 内 require vetted 库,而不是顶层。Insomnia 发现插件标签时仍在宿主进程中加载入口文件,顶层 require('uuid')/require('ajv') 在那里无法解析(它们只存在于沙箱注册表,不在插件旁边的磁盘上);相对 require(如 ./util)则可以在顶层使用。
multifileprobe:多文件插件(M4)
multifileprobe 标签 配合顶层 const greeting = require('./lib/greeting') 演示多文件插件:沙箱把插件自己的源文件读入模块映射,在沙箱内解析相对 require;插件的 node_modules 永不被参考。lib/greeting.js 的注释强调:"a bare require('uuid') 总是解析到 vetted 注册表副本,所以插件无法为已授权模块提供替换实现"("poison" 保证)。
实现上,相对模块加载器 从宿主打入的 __moduleFiles 映射解析 ./x、../x、./lib(→ ./lib/index.js)等候选,源文件以 envelope 数据(而非宿主执行的代码)身份传入,用 new Function 在沙箱内编译;路径上溯超出插件根目录(如 require('../../util'))直接判为不可解析,防止"越界路径伪装成插件内模块"。宿主读取插件源码时只加载插件目录内的 .js/.json 文件,跳过 node_modules 和点目录。
权限清单:完整参考
示例插件的 package.json 是最小活样本:
{
"name": "insomnia-plugin-sandbox-demo",
"main": "index.js",
"insomnia": {
"permissions": {
"modules": ["events", "uuid"], // require(x) 允许返回什么
"capabilities": ["storage"] // context.* 允许调用的宿主桥接组
}
}
}
PERMISSIONS.md 给出的能力轴全表如下(✅ = 基线,无需声明):
| Capability | 授权内容 | 基线? |
|---|---|---|
render |
context.util.render |
✅ |
models.read |
只读 context.util.models.* 查询 |
✅ |
util |
context.util.nodeOS / decode / encode |
✅ |
crypto |
require('crypto') 宿主函数 |
✅ |
network |
context.network.* |
需声明 |
storage |
context.store.* |
需声明 |
fs-read |
context.util.readFile |
需声明 |
app |
context.app.*、context.util.openInBrowser |
需声明 |
模块轴的对应关系(与 module-registry.ts 的注册表一致):
- 基线(无需清单):
path、crypto(TEMPLATE_TAG_BASELINE_MODULES); - 可授权:注册表内其余模块——纯 JS 重实现(如
events)与官方钉版捆绑库(uuid、ajv); - 错误契约:未授权 →
Module 'X' not permitted by manifest;已授权但未打包 →Module 'X' not available in sandbox。
迁移行为:未声明任何 permissions 块的插件按基线授权运行;若随后触达非基线模块,会收到一次性迁移通知,明确指出应添加的授权名——添加 insomnia.permissions 块并重新加载插件即可。
安全设计要点(源码级总结)
结合本示例覆盖到的源码,沙箱的关键隔离机制可以归纳为:
- 运行位置可特征检测但不可伪造:
INSOMNIA_TEMPLATE_SANDBOX标记为非可写、非可配置的全局(in-sandbox-bootstrap.ts)。 - 默认拒绝的双轴清单:模块轴(
require只能命中"授权 ∩ 注册表")与能力轴(context.*分支缺失 + 宿主桥接拒调),两条轴相互独立、分别声明。 - 安全等价物而非原始内置:所有注册表模块工厂是仓库内受信字面量(module-registry.ts 明确标注其为代码注入风险点,
registerCall还会对非函数表达式工厂直接抛错拒绝),重 JS 只来自"纯 JS 重实现"或"宿主支撑 shim"。 - 信封数据捕获后即删除:宿主打入的
__envelopeJSON/__tagName/__hostBridge在引导脚本开头被捕获进闭包并从globalThis删除,插件顶层代码无法篡改授权集或直接调用原始桥接(in-sandbox-bootstrap.ts)。 - 多文件加载的"毒化"保证:插件自带
node_modules永不参与解析,裸模块名只能命中注册表副本(in-sandbox-bootstrap.ts)。 - 沙箱内部入口点锁定:
__require、__invoke、__loadPluginEntry等在插件代码运行前被Object.defineProperty冻结,插件无法替换自身的门控(in-sandbox-bootstrap.ts)。
适用前提与限制
- 本示例是手工验证夹具,不是自动化测试套件;它依赖
npm run dev的开发环境,以及 Preferences 里的实验开关(README 原文即标注 experimental)。 - 验证输出中的
arch具体值取决于你运行 Insomnia 的机器架构;sandboxprobe在开关切换时ran in的翻转(main-process↔sandbox)才是核心观察点。 - 能力/模块的具体取值以当前仓库的 PERMISSIONS.md 与 module-registry.ts 为准——README 中提到的 M1–M4 里程碑对应注册表覆盖逐步扩大的过程,例如相对
require('./util')依赖 M4 的多文件支持,而ajv等 vetted 库的加入属于 M3。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00