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
1313 "dependencies": {
1414 "@executor/sdk": "workspace:*",
1515 "@executor/codemode-core": "workspace:*",
16− "@executor/runtime-quickjs": "workspace:*",
16+ "@executor/runtime-secure-exec": "workspace:*",
1717 "effect": "catalog:"
1818 },
1919 "devDependencies": {
packages/core/execution/src/engine.ts+2−2
@@ -8,7 +8,7 @@ import type { · 7 unmodified lines
88 ElicitationContext,
99 } from "@executor/sdk";
1010 import type { CodeExecutor, ExecuteResult, SandboxToolInvoker } from "@executor/codemode-core";
11−import { makeQuickJsExecutor } from "@executor/runtime-quickjs";
11+import { makeSecureExecExecutor } from "@executor/runtime-secure-exec";
1212
1313 import {
1414 makeExecutorToolInvoker,
@@ -282,7 +282,7 @@ const runEffect = <A>(effect: Effect.Effect<A, unknown>): Promise<A> => · 267 unmodified lines
282282
283283 export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionEngine => {
284284 const { executor } = config;
285− const codeExecutor = config.codeExecutor ?? makeQuickJsExecutor();
285+ const codeExecutor = config.codeExecutor ?? makeSecureExecExecutor();
286286 const pausedExecutions = new Map<string, PausedExecution>();
287287 let nextId = 0;
288288
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 @@
1+import type {
2+ CodeExecutor,
3+ ExecuteResult,
4+ SandboxToolInvoker,
5+} from "@executor/codemode-core";
6+import * as Cause from "effect/Cause";
7+import * as Data from "effect/Data";
8+import * as Duration from "effect/Duration";
9+import * as Effect from "effect/Effect";
10+import * as Runtime from "effect/Runtime";
11+import {
12+ allowAll,
13+ createInMemoryFileSystem,
14+ createKernel,
15+ createNodeRuntime,
16+} from "secure-exec";
17+
18+export type SecureExecExecutorOptions = {
19+ timeoutMs?: number;
20+ memoryLimitMb?: number;
21+};
22+
23+const 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+
54+const formatCauseMessage = (cause: Cause.Cause<unknown>): string =>
55+ formatUnknownMessage(Cause.squash(cause));
56+
57+class 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+
68+class SecureExecTimeoutError extends Data.TaggedError("SecureExecTimeoutError")<{
69+ readonly timeoutMs: number;
70+}> {}
71+
72+const DEFAULT_TIMEOUT_MS = 5 * 60_000;
73+const DEFAULT_MEMORY_LIMIT_MB = 128;
74+const TEXT_ENCODER = new TextEncoder();
75+
76+const 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+
87+const IPC_RESULT_PREFIX = "@@executor-result@@";
88+
89+const timeoutMessage = (timeoutMs: number): string =>
90+ `Secure-exec execution timed out after ${timeoutMs}ms`;
91+
92+type ToolSuccessEnvelope = {
93+ readonly ok: true;
94+ readonly value?: string;
95+};
96+
97+type ToolErrorEnvelope = {
98+ readonly ok: false;
99+ readonly error: string;
100+};
101+
102+type ToolEnvelope = ToolSuccessEnvelope | ToolErrorEnvelope;
103+
104+type RuntimePromiseRunner = <A, E>(
105+ effect: Effect.Effect<A, E, never>,
106+) => Promise<A>;
107+
108+type SecureExecKernel = ReturnType<typeof createKernel>;
109+
110+type KernelProcess = {
111+ readonly exitCode: number | null;
112+ wait(): Promise<number>;
113+ kill(signal?: number): void;
114+};
115+
116+type ProcessOutcome =
117+ | {
118+ readonly _tag: "Exited";
119+ readonly exitCode: number;
120+ }
121+ | {
122+ readonly _tag: "TimedOut";
123+ };
124+
125+type ProcessOutput = {
126+ readonly stdout: string;
127+ readonly stderr: string;
128+ readonly outcome: ProcessOutcome;
129+};
130+
131+const 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+
140+const 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+
149+const 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+
156+const 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+
163+const 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+
174+const 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+
181+const 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+
217+const 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+
236+const __invokeTool = SecureExec.bindings.invokeTool;
237+const __log = SecureExec.bindings.emitLog;
238+
239+const __formatLogArg = (value) => {
240+ if (typeof value === 'string') return value;
241+ try { return JSON.stringify(value); } catch { return String(value); }
242+};
243+const __formatLogLine = (args) => args.map(__formatLogArg).join(' ');
244+
245+const __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+});
262+const tools = __makeToolsProxy();
263+
264+const 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+
287+const 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+
328+const 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+
351+const 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+
392+const 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+
405+const 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+
475+export 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 @@
1+import { defineConfig } from "vitest/config";
2+
3+export default defineConfig({
4+ test: {
5+ include: ["src/**/*.test.ts"],
6+ testTimeout: 30_000,
7+ },
8+});