@open-mercato/ai-assistant 0.4.11-develop.2027.6ccddc9158 → 0.4.11-develop.2031.836b06398d
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
74
|
-
|
|
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:
|
|
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": "
|
|
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.
|
|
3
|
+
"version": "0.4.11-develop.2031.836b06398d",
|
|
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.
|
|
98
|
-
"@open-mercato/ui": "0.4.11-develop.
|
|
101
|
+
"@open-mercato/shared": "0.4.11-develop.2031.836b06398d",
|
|
102
|
+
"@open-mercato/ui": "0.4.11-develop.2031.836b06398d",
|
|
99
103
|
"zod": ">=3.23.0"
|
|
100
104
|
},
|
|
101
105
|
"devDependencies": {
|
|
102
|
-
"@open-mercato/cli": "0.4.11-develop.
|
|
106
|
+
"@open-mercato/cli": "0.4.11-develop.2031.836b06398d",
|
|
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
|
|
5
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
52
|
+
// Console proxy — fire-and-forget so logging never blocks the isolate
|
|
53
|
+
await bootstrapConsole(ctx, logs)
|
|
106
54
|
|
|
107
|
-
|
|
108
|
-
|
|
55
|
+
// Inject caller-provided globals (spec, api, context, etc.)
|
|
56
|
+
await injectGlobals(ctx, globals)
|
|
109
57
|
|
|
110
|
-
//
|
|
111
|
-
|
|
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
|
|
114
|
-
filename: 'sandbox.js',
|
|
115
|
-
})
|
|
62
|
+
const normalized = normalizeCode(code)
|
|
116
63
|
|
|
117
|
-
|
|
118
|
-
const promise = script.runInContext(ctx, { timeout })
|
|
64
|
+
const script = await isolate.compileScript(`(${normalized})()`)
|
|
119
65
|
|
|
120
|
-
//
|
|
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:
|
|
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 =
|
|
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
|
|