History

swap to secure exec

Rhys Sullivan committed 7 months ago

Commit bd2d1771 parent 7edf1d76 files · +541 −3Browse files

Unified · Split

6 files
  1. packages/core/execution/package.json +1 −1
  2. packages/core/execution/src/engine.ts +2 −2
  3. packages/kernel/runtime-secure-exec/package.json +25 −0
  4. packages/kernel/runtime-secure-exec/src/index.ts +480 −0
  5. packages/kernel/runtime-secure-exec/tsconfig.json +25 −0
  6. packages/kernel/runtime-secure-exec/vitest.config.ts +8 −0
packages/core/execution/package.json+1−1
@@ -13,7 +13,7 @@ · 12 unmodified lines
13 "dependencies": {13 "dependencies": {
14 "@executor/sdk": "workspace:*",14 "@executor/sdk": "workspace:*",
15 "@executor/codemode-core": "workspace:*",15 "@executor/codemode-core": "workspace:*",
16 "@executor/runtime-quickjs": "workspace:*",16 "@executor/runtime-secure-exec": "workspace:*",
17 "effect": "catalog:"17 "effect": "catalog:"
18 },18 },
19 "devDependencies": {19 "devDependencies": {
packages/core/execution/src/engine.ts+2−2
@@ -8,7 +8,7 @@ import type { · 7 unmodified lines
8 ElicitationContext,8 ElicitationContext,
9} from "@executor/sdk";9} from "@executor/sdk";
10import type { CodeExecutor, ExecuteResult, SandboxToolInvoker } from "@executor/codemode-core";10import type { CodeExecutor, ExecuteResult, SandboxToolInvoker } from "@executor/codemode-core";
11import { makeQuickJsExecutor } from "@executor/runtime-quickjs";11import { makeSecureExecExecutor } from "@executor/runtime-secure-exec";
12 12
13import {13import {
14 makeExecutorToolInvoker,14 makeExecutorToolInvoker,
@@ -282,7 +282,7 @@ const runEffect = <A>(effect: Effect.Effect<A, unknown>): Promise<A> => · 267 unmodified lines
282 282
283export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionEngine => {283export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionEngine => {
284 const { executor } = config;284 const { executor } = config;
285 const codeExecutor = config.codeExecutor ?? makeQuickJsExecutor();285 const codeExecutor = config.codeExecutor ?? makeSecureExecExecutor();
286 const pausedExecutions = new Map<string, PausedExecution>();286 const pausedExecutions = new Map<string, PausedExecution>();
287 let nextId = 0;287 let nextId = 0;
288 288
packages/kernel/runtime-secure-exec/package.json+25−0
@@ -0,0 +1,25 @@
1{
2 "name": "@executor/runtime-secure-exec",
3 "private": true,
4 "type": "module",
5 "version": "0.0.1",
6 "exports": {
7 ".": "./src/index.ts"
8 },
9 "scripts": {
10 "typecheck": "bunx tsc --noEmit -p tsconfig.json",
11 "test": "vitest run",
12 "test:watch": "vitest"
13 },
14 "dependencies": {
15 "@executor/codemode-core": "workspace:*",
16 "effect": "catalog:",
17 "secure-exec": "^0.2.1"
18 },
19 "devDependencies": {
20 "@effect/vitest": "catalog:",
21 "@types/node": "catalog:",
22 "bun-types": "catalog:",
23 "vitest": "catalog:"
24 }
25}
packages/kernel/runtime-secure-exec/src/index.ts+480−0
@@ -0,0 +1,480 @@
1import type {
2 CodeExecutor,
3 ExecuteResult,
4 SandboxToolInvoker,
5} from "@executor/codemode-core";
6import * as Cause from "effect/Cause";
7import * as Data from "effect/Data";
8import * as Duration from "effect/Duration";
9import * as Effect from "effect/Effect";
10import * as Runtime from "effect/Runtime";
11import {
12 allowAll,
13 createInMemoryFileSystem,
14 createKernel,
15 createNodeRuntime,
16} from "secure-exec";
17
18export type SecureExecExecutorOptions = {
19 timeoutMs?: number;
20 memoryLimitMb?: number;
21};
22
23const formatUnknownMessage = (cause: unknown): string => {
24 if (cause instanceof Error) {
25 const message = cause.message.trim();
26 return message.length > 0 ? message : cause.name;
27 }
28
29 if (typeof cause === "string") {
30 return cause;
31 }
32
33 if (
34 typeof cause === "object" &&
35 cause !== null &&
36 "message" in cause &&
37 typeof cause.message === "string"
38 ) {
39 const message = cause.message.trim();
40 if (message.length > 0) return message;
41 }
42
43 if (typeof cause === "object" && cause !== null) {
44 try {
45 return JSON.stringify(cause);
46 } catch {
47 return String(cause);
48 }
49 }
50
51 return String(cause);
52};
53
54const formatCauseMessage = (cause: Cause.Cause<unknown>): string =>
55 formatUnknownMessage(Cause.squash(cause));
56
57class SecureExecExecutionError extends Data.TaggedError(
58 "SecureExecExecutionError",
59)<{
60 readonly operation: string;
61 readonly cause: unknown;
62}> {
63 override get message() {
64 return `${this.operation}: ${formatUnknownMessage(this.cause)}`;
65 }
66}
67
68class SecureExecTimeoutError extends Data.TaggedError("SecureExecTimeoutError")<{
69 readonly timeoutMs: number;
70}> {}
71
72const DEFAULT_TIMEOUT_MS = 5 * 60_000;
73const DEFAULT_MEMORY_LIMIT_MB = 128;
74const TEXT_ENCODER = new TextEncoder();
75
76const serializeJson = (value: unknown): string | undefined => {
77 if (typeof value === "undefined") return undefined;
78 try {
79 return JSON.stringify(value);
80 } catch (cause) {
81 throw new Error(
82 `Value is not JSON serializable: ${cause instanceof Error ? cause.message : String(cause)}`,
83 );
84 }
85};
86
87const IPC_RESULT_PREFIX = "@@executor-result@@";
88
89const timeoutMessage = (timeoutMs: number): string =>
90 `Secure-exec execution timed out after ${timeoutMs}ms`;
91
92type ToolSuccessEnvelope = {
93 readonly ok: true;
94 readonly value?: string;
95};
96
97type ToolErrorEnvelope = {
98 readonly ok: false;
99 readonly error: string;
100};
101
102type ToolEnvelope = ToolSuccessEnvelope | ToolErrorEnvelope;
103
104type RuntimePromiseRunner = <A, E>(
105 effect: Effect.Effect<A, E, never>,
106) => Promise<A>;
107
108type SecureExecKernel = ReturnType<typeof createKernel>;
109
110type KernelProcess = {
111 readonly exitCode: number | null;
112 wait(): Promise<number>;
113 kill(signal?: number): void;
114};
115
116type ProcessOutcome =
117 | {
118 readonly _tag: "Exited";
119 readonly exitCode: number;
120 }
121 | {
122 readonly _tag: "TimedOut";
123 };
124
125type ProcessOutput = {
126 readonly stdout: string;
127 readonly stderr: string;
128 readonly outcome: ProcessOutcome;
129};
130
131const wrapSync = <A>(
132 operation: string,
133 fn: () => A,
134): Effect.Effect<A, SecureExecExecutionError> =>
135 Effect.try({
136 try: fn,
137 catch: (cause) => new SecureExecExecutionError({ operation, cause }),
138 }).pipe(Effect.withSpan(`secure_exec.${operation}`));
139
140const wrap = <A>(
141 operation: string,
142 fn: () => Promise<A>,
143): Effect.Effect<A, SecureExecExecutionError> =>
144 Effect.tryPromise({
145 try: fn,
146 catch: (cause) => new SecureExecExecutionError({ operation, cause }),
147 }).pipe(Effect.withSpan(`secure_exec.${operation}`));
148
149const use = <T, A>(
150 operation: string,
151 client: T,
152 fn: (client: T) => Promise<A>,
153): Effect.Effect<A, SecureExecExecutionError> =>
154 wrap(operation, () => fn(client));
155
156const useSync = <T, A>(
157 operation: string,
158 client: T,
159 fn: (client: T) => A,
160): Effect.Effect<A, SecureExecExecutionError> =>
161 wrapSync(operation, () => fn(client));
162
163const encodeToolEnvelope = (envelope: ToolEnvelope): string => {
164 try {
165 return JSON.stringify(envelope);
166 } catch (cause) {
167 return JSON.stringify({
168 ok: false,
169 error: `Tool envelope serialization failed: ${formatUnknownMessage(cause)}`,
170 } satisfies ToolErrorEnvelope);
171 }
172};
173
174const parseToolArgs = (
175 argsJson: unknown,
176): Effect.Effect<unknown, SecureExecExecutionError> =>
177 typeof argsJson === "string"
178 ? wrapSync("tool.parse_args", () => JSON.parse(argsJson))
179 : Effect.void;
180
181const invokeToolBinding = (
182 toolInvoker: SandboxToolInvoker,
183 path: unknown,
184 argsJson: unknown,
185): Effect.Effect<string, never> =>
186 Effect.gen(function* () {
187 const toolPath = String(path);
188 const args = yield* parseToolArgs(argsJson);
189
190 const envelope = yield* toolInvoker.invoke({ path: toolPath, args }).pipe(
191 Effect.flatMap((value) =>
192 wrapSync("tool.serialize_result", () => ({
193 ok: true as const,
194 value: serializeJson(value),
195 })),
196 ),
197 Effect.catchAllCause((cause) =>
198 Effect.succeed<ToolErrorEnvelope>({
199 ok: false,
200 error: formatCauseMessage(cause),
201 }),
202 ),
203 );
204
205 return encodeToolEnvelope(envelope);
206 }).pipe(
207 Effect.catchAllCause((cause) =>
208 Effect.succeed(
209 encodeToolEnvelope({
210 ok: false,
211 error: formatCauseMessage(cause),
212 }),
213 ),
214 ),
215 );
216
217const buildExecutionSource = (code: string): string => {
218 const trimmed = code.trim();
219 const looksLikeArrowFunction =
220 (trimmed.startsWith("async") || trimmed.startsWith("(")) &&
221 trimmed.includes("=>");
222
223 const body = looksLikeArrowFunction
224 ? [
225 `const __fn = (${trimmed});`,
226 "if (typeof __fn !== 'function') throw new Error('Code must evaluate to a function');",
227 "return await __fn();",
228 ].join("\n")
229 : code;
230
231 // Tool invocation uses async bindings via SecureExec.bindings.__invokeTool.
232 // Console methods use SecureExec.bindings.__log for structured log capture.
233 return `
234"use strict";
235
236const __invokeTool = SecureExec.bindings.invokeTool;
237const __log = SecureExec.bindings.emitLog;
238
239const __formatLogArg = (value) => {
240 if (typeof value === 'string') return value;
241 try { return JSON.stringify(value); } catch { return String(value); }
242};
243const __formatLogLine = (args) => args.map(__formatLogArg).join(' ');
244
245const __makeToolsProxy = (path = []) => new Proxy(() => undefined, {
246 get(_target, prop) {
247 if (prop === 'then' || typeof prop === 'symbol') return undefined;
248 return __makeToolsProxy([...path, String(prop)]);
249 },
250 apply(_target, _thisArg, args) {
251 const toolPath = path.join('.');
252 if (!toolPath) throw new Error('Tool path missing in invocation');
253 const argsJson = args[0] !== undefined ? JSON.stringify(args[0]) : undefined;
254 return Promise.resolve(__invokeTool(toolPath, argsJson))
255 .then((raw) => {
256 const envelope = JSON.parse(raw);
257 if (!envelope.ok) throw new Error(envelope.error || 'Tool invocation failed');
258 return envelope.value === undefined ? undefined : JSON.parse(envelope.value);
259 });
260 },
261});
262const tools = __makeToolsProxy();
263
264const console = {
265 log: (...args) => __log('log', __formatLogLine(args)),
266 warn: (...args) => __log('warn', __formatLogLine(args)),
267 error: (...args) => __log('error', __formatLogLine(args)),
268 info: (...args) => __log('info', __formatLogLine(args)),
269 debug: (...args) => __log('debug', __formatLogLine(args)),
270};
271
272(async () => {
273 ${body}
274})().then(
275 (v) => {
276 if (v !== undefined) process.stdout.write(${JSON.stringify(IPC_RESULT_PREFIX)} + JSON.stringify(v) + '\\n');
277 },
278 (e) => {
279 const msg = e && typeof e === 'object' ? (e.stack || e.message || String(e)) : String(e);
280 process.stderr.write(msg + '\\n');
281 process.exitCode = 1;
282 }
283);
284`.trim();
285};
286
287const acquireScopedKernel = (
288 source: string,
289 memoryLimitMb: number,
290 toolInvoker: SandboxToolInvoker,
291 logs: string[],
292 runPromise: RuntimePromiseRunner,
293) =>
294 Effect.gen(function* () {
295 const vfs = createInMemoryFileSystem();
296 yield* use("vfs.write_entry", vfs, (fs) =>
297 fs.writeFile("/entry.mjs", TEXT_ENCODER.encode(source)),
298 );
299
300 const kernel = createKernel({
301 filesystem: vfs,
302 permissions: allowAll,
303 });
304
305 yield* Effect.addFinalizer(() =>
306 use("kernel.dispose", kernel, (k) => k.dispose()).pipe(Effect.orDie),
307 );
308
309 yield* use("kernel.mount_runtime", kernel, (k) =>
310 k.mount(
311 createNodeRuntime({
312 memoryLimit: memoryLimitMb,
313 permissions: allowAll,
314 bindings: {
315 invokeTool: (path: unknown, argsJson: unknown) =>
316 runPromise(invokeToolBinding(toolInvoker, path, argsJson)),
317 emitLog: (level: unknown, line: unknown) => {
318 logs.push(`[${String(level)}] ${String(line)}`);
319 },
320 },
321 }),
322 ),
323 );
324
325 return kernel;
326 });
327
328const waitForProcess = (
329 proc: KernelProcess,
330 timeoutMs: number,
331): Effect.Effect<ProcessOutcome, SecureExecExecutionError> =>
332 use("process.wait", proc, (p) => p.wait()).pipe(
333 Effect.map(
334 (exitCode): ProcessOutcome => ({
335 _tag: "Exited",
336 exitCode,
337 }),
338 ),
339 Effect.timeoutFail({
340 duration: Duration.millis(timeoutMs),
341 onTimeout: () => new SecureExecTimeoutError({ timeoutMs }),
342 }),
343 Effect.catchTag("SecureExecTimeoutError", () =>
344 useSync("process.kill_on_timeout", proc, (p) => {
345 p.kill(9);
346 return { _tag: "TimedOut" as const };
347 }),
348 ),
349 );
350
351const runEntryProcess = (
352 kernel: SecureExecKernel,
353 timeoutMs: number,
354): Effect.Effect<ProcessOutput, SecureExecExecutionError> =>
355 Effect.gen(function* () {
356 let stdout = "";
357 let stderr = "";
358 const stdoutDecoder = new TextDecoder();
359 const stderrDecoder = new TextDecoder();
360
361 const proc = yield* useSync("process.spawn", kernel, (k) =>
362 k.spawn("node", ["/entry.mjs"], {
363 onStdout: (data: Uint8Array) => {
364 stdout += stdoutDecoder.decode(data, { stream: true });
365 },
366 onStderr: (data: Uint8Array) => {
367 stderr += stderrDecoder.decode(data, { stream: true });
368 },
369 }),
370 );
371
372 const outcome = yield* waitForProcess(proc, timeoutMs).pipe(
373 Effect.ensuring(
374 useSync("process.ensure_killed", proc, (p) => {
375 if (p.exitCode === null) {
376 p.kill(9);
377 }
378 }).pipe(Effect.orDie),
379 ),
380 );
381
382 stdout += stdoutDecoder.decode();
383 stderr += stderrDecoder.decode();
384
385 return {
386 stdout,
387 stderr,
388 outcome,
389 };
390 });
391
392const parseResultFromStdout = (stdout: string): unknown => {
393 for (const line of stdout.split("\n")) {
394 if (!line.startsWith(IPC_RESULT_PREFIX)) continue;
395 const payload = line.slice(IPC_RESULT_PREFIX.length);
396 try {
397 return JSON.parse(payload);
398 } catch {
399 return payload;
400 }
401 }
402 return null;
403};
404
405const evaluateInSecureExec = (
406 options: SecureExecExecutorOptions,
407 code: string,
408 toolInvoker: SandboxToolInvoker,
409): Effect.Effect<ExecuteResult, never> => {
410 const timeoutMs = Math.max(100, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
411 const memoryLimitMb = options.memoryLimitMb ?? DEFAULT_MEMORY_LIMIT_MB;
412 const logs: string[] = [];
413
414 return Effect.gen(function* () {
415 const runtime = yield* Effect.runtime<never>();
416 const runPromise: RuntimePromiseRunner = Runtime.runPromise(runtime);
417 const source = buildExecutionSource(code);
418
419 const processOutput = yield* Effect.scoped(
420 Effect.gen(function* () {
421 const kernel = yield* acquireScopedKernel(
422 source,
423 memoryLimitMb,
424 toolInvoker,
425 logs,
426 runPromise,
427 );
428 return yield* runEntryProcess(kernel, timeoutMs);
429 }),
430 );
431
432 if (processOutput.outcome._tag === "TimedOut") {
433 return {
434 result: null,
435 error: timeoutMessage(timeoutMs),
436 logs,
437 } satisfies ExecuteResult;
438 }
439
440 if (processOutput.outcome.exitCode !== 0) {
441 const errorOutput =
442 processOutput.stderr.trim() || processOutput.stdout.trim();
443
444 return {
445 result: null,
446 error:
447 errorOutput ||
448 `Process exited with code ${processOutput.outcome.exitCode}`,
449 logs,
450 } satisfies ExecuteResult;
451 }
452
453 return {
454 result: parseResultFromStdout(processOutput.stdout),
455 logs,
456 } satisfies ExecuteResult;
457 }).pipe(
458 Effect.catchTag("SecureExecExecutionError", (error) =>
459 Effect.succeed<ExecuteResult>({
460 result: null,
461 error: error.message,
462 logs,
463 }),
464 ),
465 Effect.catchAllCause((cause) =>
466 Effect.succeed<ExecuteResult>({
467 result: null,
468 error: formatCauseMessage(cause),
469 logs,
470 }),
471 ),
472 );
473};
474
475export const makeSecureExecExecutor = (
476 options: SecureExecExecutorOptions = {},
477): CodeExecutor => ({
478 execute: (code: string, toolInvoker: SandboxToolInvoker) =>
479 evaluateInSecureExec(options, code, toolInvoker),
480});
packages/kernel/runtime-secure-exec/tsconfig.json+25−0
@@ -0,0 +1,25 @@
1{
2 "compilerOptions": {
3 "target": "ES2022",
4 "module": "ESNext",
5 "moduleResolution": "Bundler",
6 "strict": true,
7 "skipLibCheck": true,
8 "noEmit": true,
9 "lib": ["ES2022"],
10 "types": ["bun-types", "node"],
11 "noUnusedLocals": true,
12 "noImplicitOverride": true,
13 "plugins": [
14 {
15 "name": "@effect/language-service",
16 "diagnosticSeverity": {
17 "globalErrorInEffectCatch": "off"
18 }
19 }
20 ]
21 },
22 "include": [
23 "src/**/*.ts"
24 ]
25}
packages/kernel/runtime-secure-exec/vitest.config.ts+8−0
@@ -0,0 +1,8 @@
1import { defineConfig } from "vitest/config";
2
3export default defineConfig({
4 test: {
5 include: ["src/**/*.test.ts"],
6 testTimeout: 30_000,
7 },
8});