Files
Arcrun/registry/components/code/sandbox.mjs
T
Leo 60f5f10ba5 feat(code): 新增通用 code 零件(sandbox inline JS)—— Arcrun#10 設計+PoC
n8n Code node 式逃生口:config 帶 inline JS、stdin 帶 input JSON、
stdout 回 {success,data}|{success:false,error,error_type}。

沙箱=QuickJS-wasm:user JS 跑在 QuickJS context,global 只有純 ECMAScript
內建 + 唯一 curated builtin sha256(純函式);碰不到網路/檔案/env/secret/
Worker 物件圖。資源上限:timeout(interrupt)/memory/stack/output/code size。

本輪=設計+PoC,未部署 leo21c。sandbox.mjs + test/ 為 Node/vitest 可跑實作
(12 測試全綠,含 card→envelope 與原模組 planCard 逐欄全等)。index.ts 為
Worker host 骨架、DESIGN.md 記錄機制/安全性質/生產路徑/設計岔路(A/B)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJiLCRUU2o3aSpPEzVCt2o
2026-07-06 04:27:15 +00:00

120 lines
5.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// arcrun `code` 零件 —— 沙箱核心(PoC 參考實作)
// ---------------------------------------------------------------------------
// 語義:n8n Code node 式。config 帶一段 inline user JSstdin 帶 input JSON。
// user code 只能:讀 `input`(已解析的 stdin JSON)、回傳一個 JSON-able 值。
// user code 碰不到:網路 / 檔案 / env / secret / Worker 物件圖。
//
// 隔離機制:user JS 跑在 QuickJSJS 直譯器)編成的 wasm sandbox 內。QuickJS
// context 的 global 只有純 ECMAScript 內建(Object/Array/JSON/Math/Date/String…),
// 沒有 fetch / process / require / globalThis.env / WebAssembly / 任何 host binding
// —— 「無 ambient 能力(no ambient capability by construction)」。要給的能力,
// 只能由 host 明確、逐一注入為 curated builtin(本檔 = 一個純函式 sha256)。
//
// 對齊 arcrun 契約:io_model=stdin_stdout_json、no_network_syscall、
// no_filesystem_syscall。與其他零件唯一差別=直譯器是 QuickJS-wasm 而非 TinyGo-wasm。
import { getQuickJS } from 'quickjs-emscripten';
import { createHash } from 'node:crypto';
export const DEFAULT_LIMITS = {
timeout_ms: 1000, // 執行牆鐘上限(interrupt handler 逐指令檢查 deadline
memory_bytes: 16 * 1024 * 1024, // QuickJS runtime 記憶體硬上限
max_stack_bytes: 512 * 1024, // 遞迴/深堆疊上限
max_output_bytes: 1024 * 1024, // stdout JSON 大小上限(防跑飛)
max_code_bytes: 256 * 1024, // user code 本身大小上限
};
// curated 安全 builtin:純、決定性、零 ambient 能力(不能碰網路/檔案/secret)。
// PoC 用 Node crypto 實作 sha256liveWorker)要換成 Web Crypto 的
// crypto.subtle.digest('SHA-256', …)(見 DESIGN.md「curated builtins」)。
function hostSha256(s) {
return createHash('sha256').update(s, 'utf8').digest('hex');
}
/**
* 在沙箱內跑一段 user code。
* @param {string} code user 的 inline JS(函式體:可含宣告、以 `return` 回值)
* @param {*} input 已解析的 stdin JSON(會以 `input` 綁進沙箱)
* @param {object} [opts] { limits, builtins }
* @returns {Promise<{success:true,data:*}|{success:false,error:string,error_type?:string}>}
*/
export async function runCode(code, input, opts = {}) {
const limits = { ...DEFAULT_LIMITS, ...(opts.limits || {}) };
if (typeof code !== 'string') {
return err('code must be a string', 'ContractError');
}
if (Buffer.byteLength(code, 'utf8') > limits.max_code_bytes) {
return err(`code exceeds max_code_bytes (${limits.max_code_bytes})`, 'ResourceError');
}
const QuickJS = await getQuickJS();
const runtime = QuickJS.newRuntime();
runtime.setMemoryLimit(limits.memory_bytes);
runtime.setMaxStackSize(limits.max_stack_bytes);
const deadline = Date.now() + limits.timeout_ms;
let interrupted = false;
runtime.setInterruptHandler(() => {
if (Date.now() > deadline) { interrupted = true; return true; }
return false;
});
const ctx = runtime.newContext();
try {
// 注入 curated builtinsha256(單一純函式;其餘一律不給)
const shaFn = ctx.newFunction('sha256', (argHandle) => {
const s = ctx.getString(argHandle);
return ctx.newString(hostSha256(s));
});
ctx.setProp(ctx.global, 'sha256', shaFn);
shaFn.dispose();
// 綁 input:以 JSON 字串安全穿越邊界,沙箱內 JSON.parse(不共享物件圖)
const inputJson = JSON.stringify(input === undefined ? null : input);
// 包裝:user code 當成函式體跑,回值 JSON.stringify 後交回 host。
// `input` 為唯一綁定;`sha256` 為唯一注入 builtin。
const wrapped = `(() => {
"use strict";
const input = JSON.parse(${JSON.stringify(inputJson)});
const __run = (input) => { ${code}
};
const __out = __run(input);
return JSON.stringify(__out === undefined ? null : __out);
})()`;
const evalResult = ctx.evalCode(wrapped, 'user-code.js');
if (evalResult.error) {
const detail = ctx.dump(evalResult.error);
evalResult.error.dispose();
if (interrupted) return err(`execution timed out after ${limits.timeout_ms}ms`, 'TimeoutError');
const msg = typeof detail === 'object' && detail
? `${detail.name || 'Error'}: ${detail.message || ''}`.trim()
: String(detail);
return err(msg, 'UserCodeError');
}
const outJson = ctx.getString(evalResult.value);
evalResult.value.dispose();
if (Buffer.byteLength(outJson, 'utf8') > limits.max_output_bytes) {
return err(`output exceeds max_output_bytes (${limits.max_output_bytes})`, 'ResourceError');
}
return { success: true, data: JSON.parse(outJson) };
} catch (e) {
if (interrupted) return err(`execution timed out after ${limits.timeout_ms}ms`, 'TimeoutError');
const m = e instanceof Error ? e.message : String(e);
if (/out of memory|memory/i.test(m)) return err('out of memory', 'ResourceError');
return err(m, 'SandboxError');
} finally {
ctx.dispose();
runtime.dispose();
}
}
function err(message, error_type) {
return { success: false, error: message, error_type };
}