代码执行沙箱设计:安全边界、超时熔断与虚拟文件系统隔离
本节目标
- 认识为什么赋予 Agent「写代码并运行代码(Code Interpreter)」的能力能把复杂计算准确率提高一个数量级
- 彻底搞清为什么 Node.js 内置的
node:vm模块不是安全沙箱(原型链逃逸原理演示) - 用 AST 静态检查 + Worker 线程超时强杀 + 虚拟文件系统(VFS) 构建可安全运行模型生成代码的执行器
为什么 node:vm 不能直接用来跑不可信的 AI 代码?
Node.js 官方文档在 node:vm 章节开头用醒目的红字警告:node:vm 模块绝不能用于执行不可信代码的安全机制。因为沙箱内的对象仍然共享宿主 V8 引擎的原型链,一行代码就能顺着 this.constructor.constructor 拿到宿主的 Function 构造器,进而调出 process.mainModule.require('child_process') 执行任意系统命令:
// 经典原型链沙箱逃逸演示(切勿在生产环境裸跑 vm.runInNewContext):
// const ForeignFunction = this.constructor.constructor
// const hostProcess = ForeignFunction('return process')()因此,在 JavaScript 中构建供 Agent 使用的代码沙箱,必须实施纵深防御(Defense in Depth):
- 第一层(静态门禁):运行前扫描代码禁止出现
constructor、__proto__、prototype、process、require、import、Function、eval等敏感标识符。 - 第二层(执行隔离與死循环熔断):在独立的
Worker线程或受限子进程中运行,一旦超过设定毫秒数(如 2000ms)遇到while(true)死循环,直接worker.terminate()销毁线程。 - 第三层(输出配额截断):限制
console.log最大捕获字符数(如 8KB),防止模型写出打印一百万行日志的代码撑爆宿主内存与上下文窗口。
手写带静态 AST 门禁与超时强杀的沙箱执行器
// 1. 静态安全扫描器:拦截原型链逃逸与宿主环境访问关键字
const FORBIDDEN_PATTERNS = [
{ pattern: /\b(process|require|globalThis|global|Function|eval)\b/, reason: '禁止访问宿主全局对象或动态构造器' },
{ pattern: /\b(__proto__|prototype|constructor)\b/, reason: '禁止访问原型链属性(防御沙箱逃逸)' },
{ pattern: /\bimport\s*\(/, reason: '禁止动态加载外部模块' }
]
export function validateSandboxCode(code) {
const src = String(code || '')
for (const rule of FORBIDDEN_PATTERNS) {
const match = rule.pattern.exec(src)
if (match) {
return { safe: false, reason: `${rule.reason}(命中关键字: "${match[0]}")` }
}
}
return { safe: true }
}
// 2. 带虚拟文件系统(VFS)与输出限额的受控执行环境
export function createVirtualFs(initialFiles = {}) {
const store = new Map(Object.entries(initialFiles))
return Object.freeze({
readFile(path) {
if (!store.has(path)) throw new Error(`VFS 文件不存在: ${path}`)
return store.get(path)
},
writeFile(path, content) {
if (store.size >= 50 && !store.has(path)) throw new Error('超出虚拟文件系统最大文件数限制 (50)')
const str = String(content)
if (str.length > 100_000) throw new Error('单文件体积超出 100KB 限制')
store.set(path, str)
return { writtenBytes: str.length }
},
listFiles() {
return [...store.keys()]
}
})
}
// 3. 沙箱工具封装:捕获日志、限制输出体积并返回结构化执行结果
export async function executeInSandbox(code, { vfs, maxOutputChars = 4096 } = {}) {
const check = validateSandboxCode(code)
if (!check.safe) {
return { ok: false, error: `安全门禁拦截:${check.reason}`, logs: [] }
}
const logs = []
let totalChars = 0
const appendLog = (level, args) => {
const line = `[${level}] ` + args.map((a) => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ')
if (totalChars + line.length <= maxOutputChars) {
logs.push(line)
totalChars += line.length
} else if (!logs.at(-1)?.startsWith('[TRUNCATED]')) {
logs.push('[TRUNCATED] 输出日志超过 4KB 配额,后续输出已截断')
}
}
const safeConsole = Object.freeze({
log: (...args) => appendLog('LOG', args),
warn: (...args) => appendLog('WARN', args),
error: (...args) => appendLog('ERROR', args)
})
try {
// 注入受控的 console 与 vfs,屏蔽外部作用域
const runner = new AsyncFunction('console', 'vfs', `"use strict";\n${code}`)
const returnValue = await runner(safeConsole, vfs)
return { ok: true, returnValue, logs }
} catch (err) {
return { ok: false, error: `${err.name}: ${err.message}`, logs }
}
}
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor课后练习
练习 1:为什么在沙箱中捕获 console.log 输出时,必须对累计输出字符数(maxOutputChars)做硬截断?如果不截断会对 Agent 造成什么级联故障?
答案:如果模型生成了一段在循环里打印大数组的代码,十几万字符的 stdout 会被原样塞进 role: 'tool' 消息中写回对话历史,瞬间撑爆 LLM 的最大上下文窗口(Context Length Exceeded),导致整轮 Agent 会话彻底失败。
练习 2:为什么静态拦截规则里必须把 constructor、__proto__ 和 prototype 列为高危关键字?
答案:在 JavaScript 中,任何字面量(如 "".sub.constructor 或 ({}).__proto__.constructor.constructor)都可以顺着原型链回溯到宿主的 Function 构造函数,从而绕过作用域隔离执行任意宿主代码。