代码执行沙箱设计:安全边界、超时熔断与虚拟文件系统隔离

本节目标

为什么 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):

  1. 第一层(静态门禁):运行前扫描代码禁止出现 constructor、__proto__、prototype、process、require、import、Function、eval 等敏感标识符。
  2. 第二层(执行隔离與死循环熔断):在独立的 Worker 线程或受限子进程中运行,一旦超过设定毫秒数(如 2000ms)遇到 while(true) 死循环,直接 worker.terminate() 销毁线程。
  3. 第三层(输出配额截断):限制 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 构造函数,从而绕过作用域隔离执行任意宿主代码。