|
1 | 1 | /** |
2 | | - * Code Mode executor — runs LLM-generated code in an isolated V8 sandbox. |
| 2 | + * Code Mode executor — runs LLM-generated code in a Node.js vm context. |
3 | 3 | * |
4 | | - * Deny-by-default outbound policy: |
5 | | - * - No fetch(), no XMLHttpRequest, no WebSocket — isolate has no network APIs |
6 | | - * - No require(), no import — no module loading |
7 | | - * - No process, no global — no env var leaks |
8 | | - * - Only codemode.* calls are allowed via host callback; those route through approved bindings |
| 4 | + * The vm context exposes only: a `codemode` object whose methods call back |
| 5 | + * into the host via `hostCall`. No fetch, require, process, or globals are |
| 6 | + * available — the context is created with an empty sandbox. |
9 | 7 | * |
10 | | - * The isolate receives only: __hostCall (callback) and a bootstrap that defines codemode. |
11 | | - * User code cannot bypass this to access external networks. |
| 8 | + * NOTE: Node's `vm` is NOT a security boundary (same process). This is |
| 9 | + * acceptable because the only callable functions are our own bindings, and |
| 10 | + * the server controls what code is executed. Upgrade to isolated-vm or |
| 11 | + * Cloudflare DynamicWorkerExecutor for stronger isolation if needed. |
12 | 12 | */ |
13 | 13 |
|
14 | | -import ivm from "isolated-vm"; |
| 14 | +import { createContext, runInNewContext } from "node:vm"; |
15 | 15 |
|
16 | 16 | export type ExecutionResult = |
17 | 17 | | { ok: true; result: unknown } |
18 | 18 | | { ok: false; error: string; logs?: string[] }; |
19 | 19 |
|
20 | 20 | export interface ExecutorOptions { |
21 | 21 | timeoutMs?: number; |
22 | | - memoryLimitMb?: number; |
23 | 22 | } |
24 | 23 |
|
25 | 24 | const DEFAULT_TIMEOUT_MS = 30_000; |
26 | | -const DEFAULT_MEMORY_MB = 128; |
27 | 25 |
|
28 | 26 | /** Host callback: (toolName, arg) => Promise<result> */ |
29 | 27 | export type HostBindingFn = (toolName: string, arg: unknown) => Promise<unknown>; |
30 | 28 |
|
31 | | -const BOOTSTRAP = ` |
32 | | -const codemode = { |
33 | | - get_symbols: (arg) => __hostCall('get_symbols', arg), |
34 | | - get_historical_price: (arg) => __hostCall('get_historical_price', arg), |
35 | | - get_candlestick_data: (arg) => __hostCall('get_candlestick_data', arg), |
36 | | - get_latest_price: (arg) => __hostCall('get_latest_price', arg), |
37 | | -}; |
38 | | -`.trim(); |
39 | | - |
40 | 29 | export function createExecutor(options: ExecutorOptions = {}): { |
41 | 30 | execute(code: string, hostCall: HostBindingFn): Promise<ExecutionResult>; |
42 | 31 | } { |
43 | 32 | const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; |
44 | | - const memoryLimitMb = options.memoryLimitMb ?? DEFAULT_MEMORY_MB; |
45 | 33 |
|
46 | 34 | async function execute( |
47 | 35 | code: string, |
48 | 36 | hostCall: HostBindingFn, |
49 | 37 | ): Promise<ExecutionResult> { |
50 | | - const isolate = new ivm.Isolate({ memoryLimit: memoryLimitMb }); |
51 | | - let disposed = false; |
52 | | - const dispose = () => { |
53 | | - if (!disposed) { |
54 | | - disposed = true; |
55 | | - isolate.dispose(); |
56 | | - } |
57 | | - }; |
58 | | - |
59 | 38 | try { |
60 | | - const ctx = await isolate.createContext(); |
61 | | - |
62 | | - const hostCallCallback = new ivm.Callback( |
63 | | - async (name: string, arg: unknown): Promise<unknown> => { |
64 | | - return hostCall(name, arg); |
65 | | - }, |
66 | | - { async: true }, |
| 39 | + const codemode = Object.fromEntries( |
| 40 | + ["get_symbols", "get_historical_price", "get_candlestick_data", "get_latest_price"].map( |
| 41 | + (name) => [name, (arg: unknown) => hostCall(name, arg)], |
| 42 | + ), |
67 | 43 | ); |
68 | | - ctx.global.set("__hostCall", hostCallCallback); |
69 | 44 |
|
70 | | - await ctx.eval(BOOTSTRAP, { timeout: 5_000 }); |
| 45 | + const sandbox = createContext( |
| 46 | + Object.create(null, { |
| 47 | + codemode: { value: Object.freeze(codemode) }, |
| 48 | + }), |
| 49 | + ); |
71 | 50 |
|
72 | | - const wrapped = `(async () => { ${code} })()`; |
73 | | - const resultRef = await ctx.eval(wrapped, { |
74 | | - copy: true, |
| 51 | + const trimmed = code.trim(); |
| 52 | + const isFnExpr = /^async\s+(?:\(|function\b)/.test(trimmed); |
| 53 | + const wrapped = isFnExpr |
| 54 | + ? `(${trimmed})()` |
| 55 | + : `(async () => { ${trimmed} })()`; |
| 56 | + const result = await runInNewContext(wrapped, sandbox, { |
75 | 57 | timeout: timeoutMs, |
76 | 58 | }); |
77 | 59 |
|
78 | | - dispose(); |
79 | | - return { ok: true, result: resultRef }; |
| 60 | + return { ok: true, result }; |
80 | 61 | } catch (err) { |
81 | | - dispose(); |
82 | 62 | const message = |
83 | 63 | err instanceof Error ? err.message : String(err ?? "Unknown error"); |
84 | 64 | return { ok: false, error: message }; |
|
0 commit comments