@open-mercato/ai-assistant 0.4.11-develop.2023.de078b8e22 → 0.4.11-develop.2030.b2b37af32d

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,80 +1,27 @@
1
- import vm from "node:vm";
1
+ import ivm from "isolated-vm";
2
+ const MEMORY_LIMIT_MB = parseInt(process.env.SANDBOX_MEMORY_MB ?? "32", 10);
2
3
  const MAX_LOG_ENTRIES = 100;
3
4
  const MAX_LOG_ENTRY_LENGTH = 1e3;
4
5
  function createSandbox(globals, options = {}) {
5
- const { timeout = 3e4, maxApiCalls = 50 } = options;
6
+ const { timeout = 3e4 } = options;
6
7
  return {
7
8
  async execute(code) {
8
9
  const logs = [];
9
10
  const start = Date.now();
10
- const consolProxy = {
11
- log: (...args) => pushLog(logs, args),
12
- info: (...args) => pushLog(logs, args),
13
- warn: (...args) => pushLog(logs, args),
14
- error: (...args) => pushLog(logs, args),
15
- debug: (...args) => pushLog(logs, args)
16
- };
17
- const contextGlobals = {
18
- // Safe built-ins
19
- JSON,
20
- Object,
21
- Array,
22
- Map,
23
- Set,
24
- Promise,
25
- Math,
26
- Date,
27
- RegExp,
28
- String,
29
- Number,
30
- Boolean,
31
- parseInt,
32
- parseFloat,
33
- isNaN,
34
- isFinite,
35
- encodeURIComponent,
36
- decodeURIComponent,
37
- Error,
38
- TypeError,
39
- RangeError,
40
- undefined: void 0,
41
- NaN: NaN,
42
- Infinity: Infinity,
43
- // Sandboxed console
44
- console: consolProxy,
45
- // Blocked — explicitly set to undefined
46
- require: void 0,
47
- process: void 0,
48
- global: void 0,
49
- globalThis: void 0,
50
- fetch: void 0,
51
- XMLHttpRequest: void 0,
52
- WebSocket: void 0,
53
- Buffer: void 0,
54
- setTimeout: void 0,
55
- setInterval: void 0,
56
- __dirname: void 0,
57
- __filename: void 0,
58
- // Caller-provided globals (spec, api, context, etc.)
59
- ...globals
60
- };
61
- const ctx = vm.createContext(contextGlobals);
11
+ const isolate = new ivm.Isolate({ memoryLimit: MEMORY_LIMIT_MB });
62
12
  try {
13
+ const ctx = await isolate.createContext();
14
+ await bootstrapConsole(ctx, logs);
15
+ await injectGlobals(ctx, globals);
16
+ await ctx.global.set("globalThis", void 0);
63
17
  const normalized = normalizeCode(code);
64
- const wrapped = `(${normalized})()`;
65
- const script = new vm.Script(wrapped, {
66
- filename: "sandbox.js"
67
- });
68
- const promise = script.runInContext(ctx, { timeout });
18
+ const script = await isolate.compileScript(`(${normalized})()`);
69
19
  const result = await Promise.race([
70
- promise,
20
+ script.run(ctx, { promise: true, copy: true }),
71
21
  new Promise(
72
- (_, reject) => (
73
- // Use global setTimeout (not the blocked sandbox one)
74
- globalThis.setTimeout(
75
- () => reject(new Error(`Execution timed out after ${timeout}ms`)),
76
- timeout
77
- )
22
+ (_, reject) => globalThis.setTimeout(
23
+ () => reject(new Error(`Execution timed out after ${timeout}ms`)),
24
+ timeout
78
25
  )
79
26
  )
80
27
  ]);
@@ -84,12 +31,15 @@ function createSandbox(globals, options = {}) {
84
31
  durationMs: Date.now() - start
85
32
  };
86
33
  } catch (error) {
34
+ const err = error;
87
35
  return {
88
36
  result: null,
89
- error: error instanceof Error ? error.message : String(error),
37
+ error: err?.message ?? String(err),
90
38
  logs,
91
39
  durationMs: Date.now() - start
92
40
  };
41
+ } finally {
42
+ isolate.dispose();
93
43
  }
94
44
  }
95
45
  };
@@ -103,6 +53,99 @@ function normalizeCode(code) {
103
53
  }
104
54
  return normalized;
105
55
  }
56
+ async function bootstrapConsole(ctx, logs) {
57
+ const cb = new ivm.Callback((...args) => pushLog(logs, args), { ignored: true });
58
+ await ctx.evalClosure(
59
+ `globalThis.console = {
60
+ log: (...a) => $0(...a),
61
+ info: (...a) => $0(...a),
62
+ warn: (...a) => $0(...a),
63
+ error: (...a) => $0(...a),
64
+ debug: (...a) => $0(...a),
65
+ }`,
66
+ [cb]
67
+ );
68
+ }
69
+ async function injectGlobals(ctx, globals) {
70
+ const jail = ctx.global;
71
+ for (const [key, value] of Object.entries(globals)) {
72
+ if (value === null || value === void 0) {
73
+ await jail.set(key, value);
74
+ continue;
75
+ }
76
+ if (typeof value === "function") {
77
+ await injectFn(ctx, value, `globalThis[${JSON.stringify(key)}]`);
78
+ continue;
79
+ }
80
+ if (typeof value === "object") {
81
+ const obj = value;
82
+ const dataEntries = {};
83
+ const fnProps = [];
84
+ for (const [prop, propVal] of Object.entries(obj)) {
85
+ if (typeof propVal === "function") {
86
+ fnProps.push([prop, propVal]);
87
+ } else {
88
+ dataEntries[prop] = propVal;
89
+ }
90
+ }
91
+ await jail.set(key, new ivm.ExternalCopy(dataEntries).copyInto());
92
+ for (const [prop, fn] of fnProps) {
93
+ await injectFn(ctx, fn, `globalThis[${JSON.stringify(key)}][${JSON.stringify(prop)}]`);
94
+ }
95
+ continue;
96
+ }
97
+ await jail.set(key, value);
98
+ }
99
+ }
100
+ async function injectFn(ctx, fn, target) {
101
+ const sab = new SharedArrayBuffer(4);
102
+ const signal = new Int32Array(sab);
103
+ const pending = { result: null };
104
+ const startCb = new ivm.Callback(
105
+ (...args) => {
106
+ try {
107
+ const ret = fn(...args);
108
+ const p = ret instanceof Promise ? ret : Promise.resolve(ret);
109
+ p.then(
110
+ (v) => {
111
+ pending.result = { ok: true, v };
112
+ Atomics.store(signal, 0, 1);
113
+ Atomics.notify(signal, 0);
114
+ },
115
+ (e) => {
116
+ const err = e;
117
+ pending.result = { ok: false, e: err?.message ?? String(err) };
118
+ Atomics.store(signal, 0, 1);
119
+ Atomics.notify(signal, 0);
120
+ }
121
+ );
122
+ } catch (e) {
123
+ const err = e;
124
+ pending.result = { ok: false, e: err?.message ?? String(err) };
125
+ Atomics.store(signal, 0, 1);
126
+ Atomics.notify(signal, 0);
127
+ }
128
+ },
129
+ { ignored: true }
130
+ );
131
+ const getResultCb = new ivm.Callback(() => {
132
+ const r = pending.result;
133
+ pending.result = null;
134
+ Atomics.store(signal, 0, 0);
135
+ return new ivm.ExternalCopy(r).copyInto();
136
+ });
137
+ await ctx.evalClosure(
138
+ `const _s=$0,_sig=new Int32Array(_s),_start=$1,_get=$2
139
+ ${target} = function(...a) {
140
+ _start(...a)
141
+ Atomics.wait(_sig, 0, 0)
142
+ const r = _get()
143
+ if (!r.ok) throw new Error(r.e)
144
+ return r.v
145
+ }`,
146
+ [new ivm.ExternalCopy(sab).copyInto(), startCb, getResultCb]
147
+ );
148
+ }
106
149
  function pushLog(logs, args) {
107
150
  if (logs.length >= MAX_LOG_ENTRIES) return;
108
151
  const message = args.map((arg) => {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/ai_assistant/lib/sandbox.ts"],
4
- "sourcesContent": ["/**\n * Sandboxed Code Execution Engine\n *\n * Uses node:vm to run AI-generated JavaScript in a restricted sandbox.\n * Only whitelisted globals are available \u2014 no file system, network, or process access.\n */\n\nimport vm from 'node:vm'\n\nexport interface SandboxOptions {\n /** Execution timeout in milliseconds (default: 30_000) */\n timeout?: number\n /** Maximum output size in bytes (default: 1_048_576 / 1MB) */\n maxOutputSize?: number\n /** Maximum number of api.request() calls allowed (default: 50) */\n maxApiCalls?: number\n}\n\nexport interface SandboxResult {\n result: unknown\n error?: string\n logs: string[]\n durationMs: number\n apiCallCount?: number\n}\n\nconst MAX_LOG_ENTRIES = 100\nconst MAX_LOG_ENTRY_LENGTH = 1000\n\n/**\n * Create a sandboxed execution environment.\n *\n * @param globals - Custom globals to inject (e.g., spec, api, context)\n * @param options - Sandbox configuration\n */\nexport function createSandbox(\n globals: Record<string, unknown>,\n options: SandboxOptions = {}\n) {\n const { timeout = 30_000, maxApiCalls = 50 } = options\n\n return {\n async execute(code: string): Promise<SandboxResult> {\n const logs: string[] = []\n const start = Date.now()\n\n // Capture console output\n const consolProxy = {\n log: (...args: unknown[]) => pushLog(logs, args),\n info: (...args: unknown[]) => pushLog(logs, args),\n warn: (...args: unknown[]) => pushLog(logs, args),\n error: (...args: unknown[]) => pushLog(logs, args),\n debug: (...args: unknown[]) => pushLog(logs, args),\n }\n\n // Build context with safe globals + caller-provided globals\n const contextGlobals: Record<string, unknown> = {\n // Safe built-ins\n JSON,\n Object,\n Array,\n Map,\n Set,\n Promise,\n Math,\n Date,\n RegExp,\n String,\n Number,\n Boolean,\n parseInt,\n parseFloat,\n isNaN,\n isFinite,\n encodeURIComponent,\n decodeURIComponent,\n Error,\n TypeError,\n RangeError,\n undefined,\n NaN,\n Infinity,\n\n // Sandboxed console\n console: consolProxy,\n\n // Blocked \u2014 explicitly set to undefined\n require: undefined,\n process: undefined,\n global: undefined,\n globalThis: undefined,\n fetch: undefined,\n XMLHttpRequest: undefined,\n WebSocket: undefined,\n Buffer: undefined,\n setTimeout: undefined,\n setInterval: undefined,\n __dirname: undefined,\n __filename: undefined,\n\n // Caller-provided globals (spec, api, context, etc.)\n ...globals,\n }\n\n const ctx = vm.createContext(contextGlobals)\n\n try {\n const normalized = normalizeCode(code)\n\n // Invoke the normalized async function directly\n const wrapped = `(${normalized})()`\n\n const script = new vm.Script(wrapped, {\n filename: 'sandbox.js',\n })\n\n // Run the script \u2014 returns a Promise\n const promise = script.runInContext(ctx, { timeout })\n\n // Await with secondary timeout (for async operations like api.request)\n const result = await Promise.race([\n promise,\n new Promise((_, reject) =>\n // Use global setTimeout (not the blocked sandbox one)\n globalThis.setTimeout(\n () => reject(new Error(`Execution timed out after ${timeout}ms`)),\n timeout\n )\n ),\n ])\n\n return {\n result,\n logs,\n durationMs: Date.now() - start,\n }\n } catch (error) {\n return {\n result: null,\n error: error instanceof Error ? error.message : String(error),\n logs,\n durationMs: Date.now() - start,\n }\n }\n },\n }\n}\n\n/**\n * Normalize AI-generated code: strip markdown fencing and validate shape.\n */\nexport function normalizeCode(code: string): string {\n let normalized = code.trim()\n\n // Strip markdown code fences\n normalized = normalized\n .replace(/^```(?:javascript|js|typescript|ts)?\\s*\\n?/i, '')\n .replace(/\\n?```\\s*$/, '')\n .trim()\n\n // Auto-wrap bare code into async arrow functions\n if (!/^\\s*async\\s*\\(/.test(normalized)) {\n // Detect statement-leading keywords \u2014 these cannot follow `return`\n const isStatement = /^\\s*(const|let|var|for|while|if|try|switch|return|throw|class|function)\\b/.test(normalized)\n normalized = isStatement\n ? `async () => { ${normalized} }`\n : `async () => { return ${normalized} }`\n }\n\n return normalized\n}\n\nfunction pushLog(logs: string[], args: unknown[]): void {\n if (logs.length >= MAX_LOG_ENTRIES) return\n\n const message = args\n .map((arg) => {\n if (typeof arg === 'string') return arg\n try {\n return JSON.stringify(arg)\n } catch {\n return String(arg)\n }\n })\n .join(' ')\n\n logs.push(\n message.length > MAX_LOG_ENTRY_LENGTH\n ? message.slice(0, MAX_LOG_ENTRY_LENGTH) + '...'\n : message\n )\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\n * Sandboxed Code Execution Engine\n *\n * Uses isolated-vm to run AI-generated JavaScript inside a separate V8 isolate.\n * Each execution gets a fresh isolate with no shared prototype chain, heap, or\n * handle access to the host process \u2014 preventing the node:vm escape via the\n * Promise prototype chain (NEW-01, CVSS 9.9).\n */\n\nimport ivm from 'isolated-vm'\n\nexport interface SandboxOptions {\n /** Execution timeout in milliseconds (default: 30_000) */\n timeout?: number\n /** Maximum output size in bytes (default: 1_048_576 / 1MB) */\n maxOutputSize?: number\n /** Maximum number of api.request() calls allowed (default: 50) */\n maxApiCalls?: number\n}\n\nexport interface SandboxResult {\n result: unknown\n error?: string\n logs: string[]\n durationMs: number\n apiCallCount?: number\n}\n\nconst MEMORY_LIMIT_MB = parseInt(process.env.SANDBOX_MEMORY_MB ?? '32', 10)\nconst MAX_LOG_ENTRIES = 100\nconst MAX_LOG_ENTRY_LENGTH = 1000\n\n/**\n * Create a sandboxed execution environment.\n *\n * @param globals - Custom globals to inject (e.g., spec, api, context)\n * @param options - Sandbox configuration\n */\nexport function createSandbox(globals: Record<string, unknown>, options: SandboxOptions = {}) {\n const { timeout = 30_000 } = options\n\n return {\n async execute(code: string): Promise<SandboxResult> {\n const logs: string[] = []\n const start = Date.now()\n\n const isolate = new ivm.Isolate({ memoryLimit: MEMORY_LIMIT_MB })\n\n try {\n const ctx = await isolate.createContext()\n\n // Console proxy \u2014 fire-and-forget so logging never blocks the isolate\n await bootstrapConsole(ctx, logs)\n\n // Inject caller-provided globals (spec, api, context, etc.)\n await injectGlobals(ctx, globals)\n\n // Shadow globalThis so user code cannot navigate to the isolate's global\n // object and inspect/escape via its properties\n await ctx.global.set('globalThis', undefined)\n\n const normalized = normalizeCode(code)\n\n const script = await isolate.compileScript(`(${normalized})()`)\n\n // promise: true \u2014 user code is async; awaits the returned Promise\n // copy: true \u2014 structured-clones the result back to the outer heap\n // before isolate.dispose() is called; required for\n // object/array returns (primitives work without it)\n const result = await Promise.race([\n script.run(ctx, { promise: true, copy: true }),\n new Promise<never>((_, reject) =>\n globalThis.setTimeout(\n () => reject(new Error(`Execution timed out after ${timeout}ms`)),\n timeout\n )\n ),\n ])\n\n return {\n result,\n logs,\n durationMs: Date.now() - start,\n }\n } catch (error) {\n const err = error as any\n return {\n result: null,\n error: err?.message ?? String(err),\n logs,\n durationMs: Date.now() - start,\n }\n } finally {\n // Always release the V8 isolate to avoid memory leaks\n isolate.dispose()\n }\n },\n }\n}\n\n/**\n * Normalize AI-generated code: strip markdown fencing and validate shape.\n */\nexport function normalizeCode(code: string): string {\n let normalized = code.trim()\n\n // Strip markdown code fences\n normalized = normalized\n .replace(/^```(?:javascript|js|typescript|ts)?\\s*\\n?/i, '')\n .replace(/\\n?```\\s*$/, '')\n .trim()\n\n // Auto-wrap bare code into async arrow functions\n if (!/^\\s*async\\s*\\(/.test(normalized)) {\n // Detect statement-leading keywords \u2014 these cannot follow `return`\n const isStatement =\n /^\\s*(const|let|var|for|while|if|try|switch|return|throw|class|function)\\b/.test(normalized)\n normalized = isStatement\n ? `async () => { ${normalized} }`\n : `async () => { return ${normalized} }`\n }\n\n return normalized\n}\n\n// \u2500\u2500\u2500 Private helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Inject a console proxy into the isolate that forwards to the outer logs array.\n * Uses ivm.Callback with { ignored: true } (fire-and-forget) so logging never\n * blocks the isolate event loop. Arguments are deep-copied automatically.\n */\nasync function bootstrapConsole(ctx: ivm.Context, logs: string[]): Promise<void> {\n const cb = new ivm.Callback((...args: unknown[]) => pushLog(logs, args), { ignored: true })\n\n await ctx.evalClosure(\n `globalThis.console = {\n log: (...a) => $0(...a),\n info: (...a) => $0(...a),\n warn: (...a) => $0(...a),\n error: (...a) => $0(...a),\n debug: (...a) => $0(...a),\n }`,\n [cb]\n )\n}\n\n/**\n * Inject all caller-provided globals into the isolate context.\n *\n * Strategy per value type:\n * null / undefined / primitive \u2192 jail.set directly\n * function \u2192 SAB bridge (see injectFn) \u2014 synchronous-looking\n * call inside the isolate that blocks on Atomics.wait\n * while the host resolves the async work, then returns\n * the result via a sync Callback\n * object \u2192 split: data properties via ExternalCopy,\n * function properties via SAB bridge wrappers\n */\nasync function injectGlobals(\n ctx: ivm.Context,\n globals: Record<string, unknown>\n): Promise<void> {\n const jail = ctx.global\n\n for (const [key, value] of Object.entries(globals)) {\n if (value === null || value === undefined) {\n await jail.set(key, value as null | undefined)\n continue\n }\n\n if (typeof value === 'function') {\n await injectFn(ctx, value as (...a: unknown[]) => unknown, `globalThis[${JSON.stringify(key)}]`)\n continue\n }\n\n if (typeof value === 'object') {\n const obj = value as Record<string, unknown>\n const dataEntries: Record<string, unknown> = {}\n const fnProps: Array<[string, (...a: unknown[]) => unknown]> = []\n\n for (const [prop, propVal] of Object.entries(obj)) {\n if (typeof propVal === 'function') {\n fnProps.push([prop, propVal as (...a: unknown[]) => unknown])\n } else {\n dataEntries[prop] = propVal\n }\n }\n\n // Copy data properties into the isolate first (object must exist before properties are added)\n await jail.set(key, new ivm.ExternalCopy(dataEntries).copyInto())\n\n // Add function-property SAB bridges one by one\n for (const [prop, fn] of fnProps) {\n await injectFn(ctx, fn, `globalThis[${JSON.stringify(key)}][${JSON.stringify(prop)}]`)\n }\n continue\n }\n\n // Primitive (string, number, boolean)\n await jail.set(key, value as string | number | boolean)\n }\n}\n\n/**\n * Wire a single host function into the isolate at `target` using a SAB bridge.\n *\n * How it works:\n * 1. A SharedArrayBuffer(4) acts as a one-bit signal (0 = pending, 1 = ready).\n * 2. `startCb` (fire-and-forget) launches the host async fn; when it settles it\n * stores the result in `pending` then sets signal[0] = 1 and notifies.\n * 3. `getResultCb` (sync) reads the result from `pending` and returns it as an\n * ExternalCopy so the value crosses the isolate boundary.\n * 4. Inside the isolate, `target` becomes a regular function that calls startCb,\n * blocks on Atomics.wait (does NOT block the host event loop \u2014 only the\n * isolate's worker thread), then calls getResultCb and returns or throws.\n */\nasync function injectFn(\n ctx: ivm.Context,\n fn: (...a: unknown[]) => unknown,\n target: string\n): Promise<void> {\n const sab = new SharedArrayBuffer(4)\n const signal = new Int32Array(sab)\n const pending: { result: { ok: boolean; v?: unknown; e?: string } | null } = { result: null }\n\n const startCb = new ivm.Callback(\n (...args: unknown[]) => {\n try {\n const ret = fn(...args)\n const p = ret instanceof Promise ? ret : Promise.resolve(ret)\n p.then(\n (v) => {\n pending.result = { ok: true, v }\n Atomics.store(signal, 0, 1)\n Atomics.notify(signal, 0)\n },\n (e: unknown) => {\n const err = e as any\n pending.result = { ok: false, e: err?.message ?? String(err) }\n Atomics.store(signal, 0, 1)\n Atomics.notify(signal, 0)\n }\n )\n } catch (e) {\n const err = e as any\n pending.result = { ok: false, e: err?.message ?? String(err) }\n Atomics.store(signal, 0, 1)\n Atomics.notify(signal, 0)\n }\n },\n { ignored: true }\n )\n\n const getResultCb = new ivm.Callback(() => {\n const r = pending.result!\n pending.result = null\n Atomics.store(signal, 0, 0)\n return new ivm.ExternalCopy(r).copyInto()\n })\n\n await ctx.evalClosure(\n `const _s=$0,_sig=new Int32Array(_s),_start=$1,_get=$2\n ${target} = function(...a) {\n _start(...a)\n Atomics.wait(_sig, 0, 0)\n const r = _get()\n if (!r.ok) throw new Error(r.e)\n return r.v\n }`,\n [new ivm.ExternalCopy(sab).copyInto(), startCb, getResultCb]\n )\n}\n\nfunction pushLog(logs: string[], args: unknown[]): void {\n if (logs.length >= MAX_LOG_ENTRIES) return\n\n const message = args\n .map((arg) => {\n if (typeof arg === 'string') return arg\n try {\n return JSON.stringify(arg)\n } catch {\n return String(arg)\n }\n })\n .join(' ')\n\n logs.push(\n message.length > MAX_LOG_ENTRY_LENGTH\n ? message.slice(0, MAX_LOG_ENTRY_LENGTH) + '...'\n : message\n )\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "name": "@open-mercato/ai-assistant",
3
- "version": "0.4.11-develop.2023.de078b8e22",
3
+ "version": "0.4.11-develop.2030.b2b37af32d",
4
4
  "type": "module",
5
+ "engines": {
6
+ "node": ">=22.0.0"
7
+ },
5
8
  "main": "./dist/index.js",
6
9
  "scripts": {
7
10
  "build": "node build.mjs",
@@ -89,17 +92,18 @@
89
92
  "ai": "^6.0.33",
90
93
  "cmdk": "^1.0.0",
91
94
  "framer-motion": "^11.0.0",
95
+ "isolated-vm": "^6.1.2",
92
96
  "react-json-view-lite": "^2.5.0",
93
97
  "react-markdown": "^9.0.0",
94
98
  "zod-to-json-schema": "^3.25.1"
95
99
  },
96
100
  "peerDependencies": {
97
- "@open-mercato/shared": "0.4.11-develop.2023.de078b8e22",
98
- "@open-mercato/ui": "0.4.11-develop.2023.de078b8e22",
101
+ "@open-mercato/shared": "0.4.11-develop.2030.b2b37af32d",
102
+ "@open-mercato/ui": "0.4.11-develop.2030.b2b37af32d",
99
103
  "zod": ">=3.23.0"
100
104
  },
101
105
  "devDependencies": {
102
- "@open-mercato/cli": "0.4.11-develop.2023.de078b8e22",
106
+ "@open-mercato/cli": "0.4.11-develop.2030.b2b37af32d",
103
107
  "tsx": "^4.21.0"
104
108
  },
105
109
  "publishConfig": {
@@ -182,6 +182,44 @@ describe('createSandbox', () => {
182
182
  )
183
183
  expect(result.error).toBeDefined()
184
184
  })
185
+
186
+ it('blocks prototype-chain escape via Object.constructor.constructor', async () => {
187
+ const sandbox = createSandbox({})
188
+ const result = await sandbox.execute(
189
+ "async () => Object.constructor('return process')()"
190
+ )
191
+ expect(result.result).toBeFalsy()
192
+ expect(result.error).toBeDefined()
193
+ })
194
+
195
+ it('blocks prototype-chain escape via Array.constructor.constructor', async () => {
196
+ const sandbox = createSandbox({})
197
+ const result = await sandbox.execute(
198
+ "async () => Array.constructor('return this')()"
199
+ )
200
+ const leaked = result.result as Record<string, unknown> | null | undefined
201
+ const gotHostProcess = typeof leaked === 'object' && leaked !== null
202
+ && typeof (leaked as { process?: unknown }).process === 'object'
203
+ && (leaked as { process?: { pid?: unknown } }).process?.pid !== undefined
204
+ expect(gotHostProcess).toBe(false)
205
+ })
206
+
207
+ it('blocks prototype-chain escape via Promise.constructor.constructor', async () => {
208
+ const sandbox = createSandbox({})
209
+ const result = await sandbox.execute(
210
+ "async () => Promise.constructor('return process.env')()"
211
+ )
212
+ const leaked = result.result as Record<string, unknown> | null | undefined
213
+ expect(leaked === undefined || leaked === null || Object.keys(leaked as object).length === 0).toBe(true)
214
+ })
215
+
216
+ it('blocks (() => {}).constructor escape', async () => {
217
+ const sandbox = createSandbox({})
218
+ const result = await sandbox.execute(
219
+ "async () => ((()=>{}).constructor)('return process.mainModule.require(\"child_process\").execSync(\"id\").toString()')()"
220
+ )
221
+ expect(result.error).toBeDefined()
222
+ })
185
223
  })
186
224
 
187
225
  describe('allowed built-ins', () => {
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Sandboxed Code Execution Engine
3
3
  *
4
- * Uses node:vm to run AI-generated JavaScript in a restricted sandbox.
5
- * Only whitelisted globals are available no file system, network, or process access.
4
+ * Uses isolated-vm to run AI-generated JavaScript inside a separate V8 isolate.
5
+ * Each execution gets a fresh isolate with no shared prototype chain, heap, or
6
+ * handle access to the host process — preventing the node:vm escape via the
7
+ * Promise prototype chain (NEW-01, CVSS 9.9).
6
8
  */
7
9
 
8
- import vm from 'node:vm'
10
+ import ivm from 'isolated-vm'
9
11
 
10
12
  export interface SandboxOptions {
11
13
  /** Execution timeout in milliseconds (default: 30_000) */
@@ -24,6 +26,7 @@ export interface SandboxResult {
24
26
  apiCallCount?: number
25
27
  }
26
28
 
29
+ const MEMORY_LIMIT_MB = parseInt(process.env.SANDBOX_MEMORY_MB ?? '32', 10)
27
30
  const MAX_LOG_ENTRIES = 100
28
31
  const MAX_LOG_ENTRY_LENGTH = 1000
29
32
 
@@ -33,95 +36,40 @@ const MAX_LOG_ENTRY_LENGTH = 1000
33
36
  * @param globals - Custom globals to inject (e.g., spec, api, context)
34
37
  * @param options - Sandbox configuration
35
38
  */
36
- export function createSandbox(
37
- globals: Record<string, unknown>,
38
- options: SandboxOptions = {}
39
- ) {
40
- const { timeout = 30_000, maxApiCalls = 50 } = options
39
+ export function createSandbox(globals: Record<string, unknown>, options: SandboxOptions = {}) {
40
+ const { timeout = 30_000 } = options
41
41
 
42
42
  return {
43
43
  async execute(code: string): Promise<SandboxResult> {
44
44
  const logs: string[] = []
45
45
  const start = Date.now()
46
46
 
47
- // Capture console output
48
- const consolProxy = {
49
- log: (...args: unknown[]) => pushLog(logs, args),
50
- info: (...args: unknown[]) => pushLog(logs, args),
51
- warn: (...args: unknown[]) => pushLog(logs, args),
52
- error: (...args: unknown[]) => pushLog(logs, args),
53
- debug: (...args: unknown[]) => pushLog(logs, args),
54
- }
47
+ const isolate = new ivm.Isolate({ memoryLimit: MEMORY_LIMIT_MB })
55
48
 
56
- // Build context with safe globals + caller-provided globals
57
- const contextGlobals: Record<string, unknown> = {
58
- // Safe built-ins
59
- JSON,
60
- Object,
61
- Array,
62
- Map,
63
- Set,
64
- Promise,
65
- Math,
66
- Date,
67
- RegExp,
68
- String,
69
- Number,
70
- Boolean,
71
- parseInt,
72
- parseFloat,
73
- isNaN,
74
- isFinite,
75
- encodeURIComponent,
76
- decodeURIComponent,
77
- Error,
78
- TypeError,
79
- RangeError,
80
- undefined,
81
- NaN,
82
- Infinity,
83
-
84
- // Sandboxed console
85
- console: consolProxy,
86
-
87
- // Blocked — explicitly set to undefined
88
- require: undefined,
89
- process: undefined,
90
- global: undefined,
91
- globalThis: undefined,
92
- fetch: undefined,
93
- XMLHttpRequest: undefined,
94
- WebSocket: undefined,
95
- Buffer: undefined,
96
- setTimeout: undefined,
97
- setInterval: undefined,
98
- __dirname: undefined,
99
- __filename: undefined,
100
-
101
- // Caller-provided globals (spec, api, context, etc.)
102
- ...globals,
103
- }
49
+ try {
50
+ const ctx = await isolate.createContext()
104
51
 
105
- const ctx = vm.createContext(contextGlobals)
52
+ // Console proxy — fire-and-forget so logging never blocks the isolate
53
+ await bootstrapConsole(ctx, logs)
106
54
 
107
- try {
108
- const normalized = normalizeCode(code)
55
+ // Inject caller-provided globals (spec, api, context, etc.)
56
+ await injectGlobals(ctx, globals)
109
57
 
110
- // Invoke the normalized async function directly
111
- const wrapped = `(${normalized})()`
58
+ // Shadow globalThis so user code cannot navigate to the isolate's global
59
+ // object and inspect/escape via its properties
60
+ await ctx.global.set('globalThis', undefined)
112
61
 
113
- const script = new vm.Script(wrapped, {
114
- filename: 'sandbox.js',
115
- })
62
+ const normalized = normalizeCode(code)
116
63
 
117
- // Run the script returns a Promise
118
- const promise = script.runInContext(ctx, { timeout })
64
+ const script = await isolate.compileScript(`(${normalized})()`)
119
65
 
120
- // Await with secondary timeout (for async operations like api.request)
66
+ // promise: true — user code is async; awaits the returned Promise
67
+ // copy: true — structured-clones the result back to the outer heap
68
+ // before isolate.dispose() is called; required for
69
+ // object/array returns (primitives work without it)
121
70
  const result = await Promise.race([
122
- promise,
123
- new Promise((_, reject) =>
124
- // Use global setTimeout (not the blocked sandbox one)
71
+ script.run(ctx, { promise: true, copy: true }),
72
+ new Promise<never>((_, reject) =>
125
73
  globalThis.setTimeout(
126
74
  () => reject(new Error(`Execution timed out after ${timeout}ms`)),
127
75
  timeout
@@ -135,12 +83,16 @@ export function createSandbox(
135
83
  durationMs: Date.now() - start,
136
84
  }
137
85
  } catch (error) {
86
+ const err = error as any
138
87
  return {
139
88
  result: null,
140
- error: error instanceof Error ? error.message : String(error),
89
+ error: err?.message ?? String(err),
141
90
  logs,
142
91
  durationMs: Date.now() - start,
143
92
  }
93
+ } finally {
94
+ // Always release the V8 isolate to avoid memory leaks
95
+ isolate.dispose()
144
96
  }
145
97
  },
146
98
  }
@@ -161,7 +113,8 @@ export function normalizeCode(code: string): string {
161
113
  // Auto-wrap bare code into async arrow functions
162
114
  if (!/^\s*async\s*\(/.test(normalized)) {
163
115
  // Detect statement-leading keywords — these cannot follow `return`
164
- const isStatement = /^\s*(const|let|var|for|while|if|try|switch|return|throw|class|function)\b/.test(normalized)
116
+ const isStatement =
117
+ /^\s*(const|let|var|for|while|if|try|switch|return|throw|class|function)\b/.test(normalized)
165
118
  normalized = isStatement
166
119
  ? `async () => { ${normalized} }`
167
120
  : `async () => { return ${normalized} }`
@@ -170,6 +123,155 @@ export function normalizeCode(code: string): string {
170
123
  return normalized
171
124
  }
172
125
 
126
+ // ─── Private helpers ──────────────────────────────────────────────────────────
127
+
128
+ /**
129
+ * Inject a console proxy into the isolate that forwards to the outer logs array.
130
+ * Uses ivm.Callback with { ignored: true } (fire-and-forget) so logging never
131
+ * blocks the isolate event loop. Arguments are deep-copied automatically.
132
+ */
133
+ async function bootstrapConsole(ctx: ivm.Context, logs: string[]): Promise<void> {
134
+ const cb = new ivm.Callback((...args: unknown[]) => pushLog(logs, args), { ignored: true })
135
+
136
+ await ctx.evalClosure(
137
+ `globalThis.console = {
138
+ log: (...a) => $0(...a),
139
+ info: (...a) => $0(...a),
140
+ warn: (...a) => $0(...a),
141
+ error: (...a) => $0(...a),
142
+ debug: (...a) => $0(...a),
143
+ }`,
144
+ [cb]
145
+ )
146
+ }
147
+
148
+ /**
149
+ * Inject all caller-provided globals into the isolate context.
150
+ *
151
+ * Strategy per value type:
152
+ * null / undefined / primitive → jail.set directly
153
+ * function → SAB bridge (see injectFn) — synchronous-looking
154
+ * call inside the isolate that blocks on Atomics.wait
155
+ * while the host resolves the async work, then returns
156
+ * the result via a sync Callback
157
+ * object → split: data properties via ExternalCopy,
158
+ * function properties via SAB bridge wrappers
159
+ */
160
+ async function injectGlobals(
161
+ ctx: ivm.Context,
162
+ globals: Record<string, unknown>
163
+ ): Promise<void> {
164
+ const jail = ctx.global
165
+
166
+ for (const [key, value] of Object.entries(globals)) {
167
+ if (value === null || value === undefined) {
168
+ await jail.set(key, value as null | undefined)
169
+ continue
170
+ }
171
+
172
+ if (typeof value === 'function') {
173
+ await injectFn(ctx, value as (...a: unknown[]) => unknown, `globalThis[${JSON.stringify(key)}]`)
174
+ continue
175
+ }
176
+
177
+ if (typeof value === 'object') {
178
+ const obj = value as Record<string, unknown>
179
+ const dataEntries: Record<string, unknown> = {}
180
+ const fnProps: Array<[string, (...a: unknown[]) => unknown]> = []
181
+
182
+ for (const [prop, propVal] of Object.entries(obj)) {
183
+ if (typeof propVal === 'function') {
184
+ fnProps.push([prop, propVal as (...a: unknown[]) => unknown])
185
+ } else {
186
+ dataEntries[prop] = propVal
187
+ }
188
+ }
189
+
190
+ // Copy data properties into the isolate first (object must exist before properties are added)
191
+ await jail.set(key, new ivm.ExternalCopy(dataEntries).copyInto())
192
+
193
+ // Add function-property SAB bridges one by one
194
+ for (const [prop, fn] of fnProps) {
195
+ await injectFn(ctx, fn, `globalThis[${JSON.stringify(key)}][${JSON.stringify(prop)}]`)
196
+ }
197
+ continue
198
+ }
199
+
200
+ // Primitive (string, number, boolean)
201
+ await jail.set(key, value as string | number | boolean)
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Wire a single host function into the isolate at `target` using a SAB bridge.
207
+ *
208
+ * How it works:
209
+ * 1. A SharedArrayBuffer(4) acts as a one-bit signal (0 = pending, 1 = ready).
210
+ * 2. `startCb` (fire-and-forget) launches the host async fn; when it settles it
211
+ * stores the result in `pending` then sets signal[0] = 1 and notifies.
212
+ * 3. `getResultCb` (sync) reads the result from `pending` and returns it as an
213
+ * ExternalCopy so the value crosses the isolate boundary.
214
+ * 4. Inside the isolate, `target` becomes a regular function that calls startCb,
215
+ * blocks on Atomics.wait (does NOT block the host event loop — only the
216
+ * isolate's worker thread), then calls getResultCb and returns or throws.
217
+ */
218
+ async function injectFn(
219
+ ctx: ivm.Context,
220
+ fn: (...a: unknown[]) => unknown,
221
+ target: string
222
+ ): Promise<void> {
223
+ const sab = new SharedArrayBuffer(4)
224
+ const signal = new Int32Array(sab)
225
+ const pending: { result: { ok: boolean; v?: unknown; e?: string } | null } = { result: null }
226
+
227
+ const startCb = new ivm.Callback(
228
+ (...args: unknown[]) => {
229
+ try {
230
+ const ret = fn(...args)
231
+ const p = ret instanceof Promise ? ret : Promise.resolve(ret)
232
+ p.then(
233
+ (v) => {
234
+ pending.result = { ok: true, v }
235
+ Atomics.store(signal, 0, 1)
236
+ Atomics.notify(signal, 0)
237
+ },
238
+ (e: unknown) => {
239
+ const err = e as any
240
+ pending.result = { ok: false, e: err?.message ?? String(err) }
241
+ Atomics.store(signal, 0, 1)
242
+ Atomics.notify(signal, 0)
243
+ }
244
+ )
245
+ } catch (e) {
246
+ const err = e as any
247
+ pending.result = { ok: false, e: err?.message ?? String(err) }
248
+ Atomics.store(signal, 0, 1)
249
+ Atomics.notify(signal, 0)
250
+ }
251
+ },
252
+ { ignored: true }
253
+ )
254
+
255
+ const getResultCb = new ivm.Callback(() => {
256
+ const r = pending.result!
257
+ pending.result = null
258
+ Atomics.store(signal, 0, 0)
259
+ return new ivm.ExternalCopy(r).copyInto()
260
+ })
261
+
262
+ await ctx.evalClosure(
263
+ `const _s=$0,_sig=new Int32Array(_s),_start=$1,_get=$2
264
+ ${target} = function(...a) {
265
+ _start(...a)
266
+ Atomics.wait(_sig, 0, 0)
267
+ const r = _get()
268
+ if (!r.ok) throw new Error(r.e)
269
+ return r.v
270
+ }`,
271
+ [new ivm.ExternalCopy(sab).copyInto(), startCb, getResultCb]
272
+ )
273
+ }
274
+
173
275
  function pushLog(logs: string[], args: unknown[]): void {
174
276
  if (logs.length >= MAX_LOG_ENTRIES) return
175
277