{"version":3,"file":"sandbox.d.ts","sourceRoot":"","sources":["../../../src/core/evaluation/sandbox.ts"],"names":[],"mappings":"AAOA,OAAO,EACN,KAAK,oBAAoB,EACzB,KAAK,2BAA2B,EAGhC,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EACX,yBAAyB,EACzB,eAAe,EACf,qBAAqB,EAErB,yBAAyB,EACzB,MAAM,YAAY,CAAC;AAIpB,MAAM,WAAW,uBAAuB;IACvC,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,aAAa,EAAE,MAAM,GAAG,eAAe,GAAG,eAAe,GAAG,oBAAoB,CAAC;IACjF,gBAAgB,EAAE,MAAM,CAAC;IACzB,gBAAgB,EAAE,MAAM,CAAC;IACzB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,kBAAkB,EAAE,MAAM,CAAC;IAC3B,gBAAgB,EAAE,MAAM,CAAC;IACzB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;OAKG;IACH,kBAAkB,CAAC,EAAE,2BAA2B,CAAC;IACjD,8DAA8D;IAC9D,qBAAqB,CAAC,EAAE,oBAAoB,EAAE,CAAC;CAC/C;AAED,MAAM,WAAW,oBAAoB;IACpC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,MAAM,EAAE,MAAM,CAAC,OAAO,GAAG,IAAI,CAAC;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,OAAO,CAAC;IAClB,SAAS,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,iBAAiB;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,EAAE,yBAAyB,CAAC;IAC7C,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,uBAAuB,CAAC,CAAC;IACnD,QAAQ,CAAC,MAAM,EAAE,eAAe,EAAE,CAAC;IACnC,UAAU,CACT,OAAO,EAAE,MAAM,EACf,IAAI,CAAC,EAAE,MAAM,EAAE,EACf,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;KAAE,GAC9D,OAAO,CAAC,oBAAoB,CAAC,CAAC;IACjC,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACxB,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACzB;AAED,wBAAgB,mBAAmB,CAClC,SAAS,EAAE,yBAAyB,EACpC,iBAAiB,SAAK,EACtB,QAAQ,CAAC,EAAE,2BAA2B,EACtC,qBAAqB,CAAC,EAAE,oBAAoB,EAAE,GAC5C,uBAAuB,CAsBzB;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAOzE;AAkLD,wBAAsB,uBAAuB,CAAC,KAAK,EAAE;IACpD,eAAe,EAAE,MAAM,CAAC;IACxB,OAAO,EAAE,qBAAqB,CAAC;IAC/B,MAAM,EAAE,yBAAyB,CAAC;IAClC,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,QAAQ,CAAC,EAAE,2BAA2B,CAAC;IACvC,qBAAqB,CAAC,EAAE,oBAAoB,EAAE,CAAC;CAC/C,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAyD7B;AAED,wBAAsB,oBAAoB,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAGpF;AAED,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,yBAAyB,CAAC,CAG1F","sourcesContent":["import { execFile, spawn } from \"node:child_process\";\nimport { randomUUID } from \"node:crypto\";\nimport { chmod, mkdir, readdir, readFile, rm, stat, writeFile } from \"node:fs/promises\";\nimport { basename, resolve, sep } from \"node:path\";\nimport { promisify } from \"node:util\";\nimport { cleanupFixture, type MaterializedFixture, materializeFixture } from \"./fixtures.js\";\nimport { sha256 } from \"./identity.js\";\nimport {\n\ttype AuthorizedExecutable,\n\ttype EvaluationCandidateLauncher,\n\tresolveExecutable,\n\tsamePath,\n} from \"./launcher.js\";\nimport type {\n\tEvaluationCandidatePolicy,\n\tEvaluationEvent,\n\tEvaluationFixtureSpec,\n\tEvaluationSandboxEventType,\n\tEvaluationSandboxIdentity,\n} from \"./types.js\";\n\nconst execFileAsync = promisify(execFile);\n\nexport interface EvaluationSandboxPolicy {\n\tallowedTools: string[];\n\tdeniedEffects: string[];\n\tworkspaceBoundary: string;\n\tnetworkPolicy: \"none\" | \"loopback_only\" | \"provider_only\" | \"explicit_allowlist\";\n\tmaximumProcesses: number;\n\tmaximumToolCalls: number;\n\tmaximumWallTimeMs: number;\n\tmaximumOutputBytes: number;\n\tmaximumDiskBytes: number;\n\tmaximumCostUsd?: number;\n\t/**\n\t * The trusted candidate launcher, authorized by executable identity rather\n\t * than basename. This authority is separate from `allowedTools`: it starts\n\t * the Jensen candidate runtime and does not grant the candidate permission\n\t * to invoke arbitrary executables sharing a name.\n\t */\n\tauthorizedLauncher?: EvaluationCandidateLauncher;\n\t/** Logical capability -> resolved executable identity map. */\n\tauthorizedExecutables?: AuthorizedExecutable[];\n}\n\nexport interface SandboxProcessResult {\n\texitCode: number | null;\n\tsignal: NodeJS.Signals | null;\n\tstdout: string;\n\tstderr: string;\n\tdurationMs: number;\n\ttimedOut: boolean;\n\tcancelled: boolean;\n}\n\nexport interface EvaluationSandbox {\n\treadonly root: string;\n\treadonly identity: EvaluationSandboxIdentity;\n\treadonly policy: Readonly<EvaluationSandboxPolicy>;\n\treadonly events: EvaluationEvent[];\n\trunProcess(\n\t\tcommand: string,\n\t\targs?: string[],\n\t\toptions?: { signal?: AbortSignal; env?: Record<string, string> },\n\t): Promise<SandboxProcessResult>;\n\tretain(): Promise<void>;\n\tcleanup(): Promise<void>;\n}\n\nexport function policyFromCandidate(\n\tcandidate: EvaluationCandidatePolicy,\n\tworkspaceBoundary = \"\",\n\tlauncher?: EvaluationCandidateLauncher,\n\tauthorizedExecutables?: AuthorizedExecutable[],\n): EvaluationSandboxPolicy {\n\tconst budget = candidate.budget;\n\tconst policy: EvaluationSandboxPolicy = {\n\t\tallowedTools: [...(candidate.allowedTools ?? [\"node\", \"git\"])].sort(),\n\t\tdeniedEffects: [...(candidate.deniedEffects ?? [\"publish\", \"credential_read\", \"evaluator_write\"])].sort(),\n\t\tworkspaceBoundary: candidate.workspaceBoundary ?? workspaceBoundary,\n\t\tnetworkPolicy: candidate.networkPolicy ?? (candidate.allowNetwork ? \"explicit_allowlist\" : \"none\"),\n\t\tmaximumProcesses: candidate.maximumProcesses ?? 32,\n\t\tmaximumToolCalls: candidate.maximumToolCalls ?? budget?.maximumToolCalls ?? 64,\n\t\tmaximumWallTimeMs: candidate.maximumWallTimeMs ?? budget?.maximumWallTimeMs ?? 120_000,\n\t\tmaximumOutputBytes: candidate.maximumOutputBytes ?? budget?.maximumOutputBytes ?? 1_000_000,\n\t\tmaximumDiskBytes: candidate.maximumDiskBytes ?? budget?.maximumDiskBytes ?? 10_000_000,\n\t\tmaximumCostUsd: candidate.maximumCostUsd ?? budget?.maximumCostUsd,\n\t\tauthorizedLauncher: launcher,\n\t\tauthorizedExecutables,\n\t};\n\tif (policy.maximumProcesses < 1) throw new Error(\"maximumProcesses must be positive\");\n\tif (policy.maximumToolCalls < 0) throw new Error(\"maximumToolCalls must not be negative\");\n\tif (policy.maximumWallTimeMs < 1) throw new Error(\"maximumWallTimeMs must be positive\");\n\tif (policy.maximumOutputBytes < 1) throw new Error(\"maximumOutputBytes must be positive\");\n\tif (policy.maximumDiskBytes < 1) throw new Error(\"maximumDiskBytes must be positive\");\n\treturn policy;\n}\n\nexport function assertSandboxPath(root: string, candidate: string): string {\n\tconst resolvedRoot = resolve(root);\n\tconst resolvedCandidate = resolve(resolvedRoot, candidate);\n\tconst rootPrefix = `${resolvedRoot}${sep}`;\n\tif (resolvedCandidate !== resolvedRoot && !resolvedCandidate.startsWith(rootPrefix))\n\t\tthrow new Error(`sandbox path escapes workspace boundary: ${candidate}`);\n\treturn resolvedCandidate;\n}\n\nfunction event(type: EvaluationSandboxEventType, details: Record<string, string | number | boolean>): EvaluationEvent {\n\treturn { eventId: randomUUID(), type, timestamp: new Date().toISOString(), details };\n}\n\nasync function directorySize(root: string): Promise<number> {\n\tconst entries = await readdir(root, { withFileTypes: true }).catch((error: unknown) => {\n\t\tif ((error as NodeJS.ErrnoException).code === \"ENOENT\") return [];\n\t\tthrow error;\n\t});\n\tlet total = 0;\n\tfor (const entry of entries) {\n\t\tconst path = assertSandboxPath(root, entry.name);\n\t\tif (entry.isDirectory()) total += await directorySize(path);\n\t\telse if (entry.isFile()) total += (await stat(path)).size;\n\t}\n\treturn total;\n}\n\nasync function makeReadOnly(root: string): Promise<void> {\n\tconst entries = await readdir(root, { withFileTypes: true }).catch((error: unknown) => {\n\t\tif ((error as NodeJS.ErrnoException).code === \"ENOENT\") return [];\n\t\tthrow error;\n\t});\n\tfor (const entry of entries) {\n\t\tconst path = assertSandboxPath(root, entry.name);\n\t\tif (entry.isDirectory()) await makeReadOnly(path);\n\t\telse if (entry.isFile()) await chmod(path, 0o444);\n\t}\n\tawait chmod(root, 0o555);\n}\n\nfunction safeEnvironment(\n\tinput: Record<string, string> | undefined,\n\troot: string,\n\tpolicy: EvaluationSandboxPolicy,\n): NodeJS.ProcessEnv {\n\tconst environment: NodeJS.ProcessEnv = {\n\t\tPATH: process.env.PATH,\n\t\tHOME: root,\n\t\tTMPDIR: root,\n\t\tTEMP: root,\n\t\tTMP: root,\n\t\tJENSEN_EVALUATION_SANDBOX: \"1\",\n\t\tJENSEN_EVALUATION_NETWORK_POLICY: policy.networkPolicy,\n\t};\n\tfor (const [key, value] of Object.entries(input ?? {})) {\n\t\tif (/token|secret|password|api[_-]?key|private[_-]?key|auth/i.test(key))\n\t\t\tthrow new Error(`sandbox environment rejects secret-like variable: ${key}`);\n\t\tenvironment[key] = value;\n\t}\n\treturn environment;\n}\n\nasync function terminateProcess(child: ReturnType<typeof spawn>): Promise<void> {\n\tif (child.pid === undefined) return;\n\tif (process.platform === \"win32\") {\n\t\tawait execFileAsync(\"taskkill\", [\"/PID\", String(child.pid), \"/T\", \"/F\"]).catch(() => undefined);\n\t\treturn;\n\t}\n\ttry {\n\t\tprocess.kill(-child.pid, \"SIGTERM\");\n\t} catch {\n\t\tchild.kill(\"SIGTERM\");\n\t}\n\tawait new Promise((resolvePromise) => setTimeout(resolvePromise, 50));\n\ttry {\n\t\tprocess.kill(-child.pid, \"SIGKILL\");\n\t} catch {\n\t\tchild.kill(\"SIGKILL\");\n\t}\n}\n\nasync function runProcess(\n\troot: string,\n\tpolicy: EvaluationSandboxPolicy,\n\tevents: EvaluationEvent[],\n\tcommand: string,\n\targs: string[],\n\toptions: { signal?: AbortSignal; env?: Record<string, string> } = {},\n): Promise<SandboxProcessResult> {\n\tconst toolName = basename(command).replace(/\\.exe$/i, \"\");\n\tconst launcher = policy.authorizedLauncher;\n\tconst launcherIdentity = launcher ? await resolveExecutable(command, root) : undefined;\n\tconst isTrustedLauncher =\n\t\tBoolean(launcher) &&\n\t\tBoolean(launcherIdentity) &&\n\t\tlauncherIdentity?.exists === true &&\n\t\tsamePath(launcherIdentity.realPath ?? launcherIdentity.resolvedPath, launcher!.executablePath);\n\tif (isTrustedLauncher) {\n\t\tevents.push(\n\t\t\tevent(\"EVAL_LAUNCHER_AUTHORIZED\", {\n\t\t\t\tlauncher: launcher!.executableIdentity,\n\t\t\t\texecutablePath: launcher!.executablePath,\n\t\t\t}),\n\t\t);\n\t} else if (policy.allowedTools.length > 0 && !policy.allowedTools.includes(toolName)) {\n\t\tevents.push(event(\"EVAL_LAUNCHER_REJECTED\", { toolName }));\n\t\tthrow new Error(`sandbox tool is not allowed: ${toolName}`);\n\t}\n\tfor (const argument of args) {\n\t\tif (/\\.jensen[\\\\/]evaluations|baseline|evaluator|result\\.json|\\.env|npmrc/i.test(argument))\n\t\t\tthrow new Error(\"sandbox policy denied evaluator, baseline, result, or credential path\");\n\t\tif (isAbsoluteLike(argument) && !argument.startsWith(resolve(root)))\n\t\t\tthrow new Error(`sandbox argument escapes workspace boundary: ${argument}`);\n\t}\n\tconst started = Date.now();\n\tconst child = spawn(command, args, {\n\t\tcwd: root,\n\t\tenv: safeEnvironment(options.env, root, policy),\n\t\tstdio: [\"ignore\", \"pipe\", \"pipe\"],\n\t\tdetached: process.platform !== \"win32\",\n\t\twindowsHide: true,\n\t});\n\tevents.push(event(\"EVAL_CANDIDATE_STARTED\", { command: basename(command) }));\n\tlet stdout = \"\";\n\tlet stderr = \"\";\n\tlet exceededOutput = false;\n\tlet timedOut = false;\n\tlet cancelled = false;\n\tconst append = (current: string, chunk: Buffer): string => {\n\t\tconst next = `${current}${chunk.toString(\"utf8\")}`;\n\t\tif (Buffer.byteLength(next) > policy.maximumOutputBytes) {\n\t\t\texceededOutput = true;\n\t\t\tvoid terminateProcess(child);\n\t\t\treturn next.slice(0, policy.maximumOutputBytes);\n\t\t}\n\t\treturn next;\n\t};\n\tchild.stdout?.on(\"data\", (chunk: Buffer) => {\n\t\tstdout = append(stdout, chunk);\n\t});\n\tchild.stderr?.on(\"data\", (chunk: Buffer) => {\n\t\tstderr = append(stderr, chunk);\n\t});\n\tconst timeout = setTimeout(() => {\n\t\ttimedOut = true;\n\t\tvoid terminateProcess(child);\n\t}, policy.maximumWallTimeMs);\n\tconst onAbort = () => {\n\t\tcancelled = true;\n\t\tvoid terminateProcess(child);\n\t};\n\toptions.signal?.addEventListener(\"abort\", onAbort, { once: true });\n\tconst result = await new Promise<{ exitCode: number | null; signal: NodeJS.Signals | null }>(\n\t\t(resolvePromise, reject) => {\n\t\t\tchild.once(\"error\", reject);\n\t\t\tchild.once(\"exit\", (exitCode, signal) => resolvePromise({ exitCode, signal }));\n\t\t},\n\t);\n\tclearTimeout(timeout);\n\toptions.signal?.removeEventListener(\"abort\", onAbort);\n\tconst diskBytes = await directorySize(root);\n\tif (diskBytes > policy.maximumDiskBytes) {\n\t\tevents.push(event(\"EVAL_CANDIDATE_FAILED\", { reason: \"disk_limit\", diskBytes }));\n\t\tthrow new Error(`sandbox disk limit exceeded: ${diskBytes}`);\n\t}\n\tif (exceededOutput) {\n\t\tevents.push(event(\"EVAL_CANDIDATE_FAILED\", { reason: \"output_limit\" }));\n\t\tthrow new Error(\"sandbox output limit exceeded\");\n\t}\n\tif (timedOut) {\n\t\tevents.push(event(\"EVAL_CANDIDATE_FAILED\", { reason: \"timeout\" }));\n\t\tthrow new Error(\"sandbox wall-time limit exceeded\");\n\t}\n\tif (cancelled) {\n\t\tevents.push(event(\"EVAL_CANDIDATE_FAILED\", { reason: \"cancelled\" }));\n\t\tthrow new Error(\"sandbox execution cancelled\");\n\t}\n\tevents.push(event(\"EVAL_CANDIDATE_COMPLETED\", { exitCode: result.exitCode ?? -1 }));\n\treturn { ...result, stdout, stderr, durationMs: Date.now() - started, timedOut, cancelled };\n}\n\nfunction isAbsoluteLike(value: string): boolean {\n\treturn value.startsWith(\"/\") || /^[A-Za-z]:[\\\\/]/.test(value) || value.startsWith(\"\\\\\\\\\");\n}\n\nexport async function createEvaluationSandbox(input: {\n\tevaluationRunId: string;\n\tfixture: EvaluationFixtureSpec;\n\tpolicy: EvaluationCandidatePolicy;\n\tretainOnFailure?: boolean;\n\tsignal?: AbortSignal;\n\tlauncher?: EvaluationCandidateLauncher;\n\tauthorizedExecutables?: AuthorizedExecutable[];\n}): Promise<EvaluationSandbox> {\n\tconst events: EvaluationEvent[] = [];\n\tlet fixture: MaterializedFixture | undefined;\n\tlet retained = false;\n\ttry {\n\t\tfixture = await materializeFixture(input.fixture, { retainOnFailure: false });\n\t\tconst root = resolve(fixture.root);\n\t\tconst policy = Object.freeze(\n\t\t\tpolicyFromCandidate(input.policy, root, input.launcher, input.authorizedExecutables),\n\t\t);\n\t\tconst identity: EvaluationSandboxIdentity = {\n\t\t\tsandboxId: randomUUID(),\n\t\t\tevaluationRunId: input.evaluationRunId,\n\t\t\tcanonicalRoot: root,\n\t\t\tfixtureHash: fixture.fixtureHash,\n\t\t\tplatform: `${process.platform}-${process.arch}`,\n\t\t\tcreatedAt: new Date().toISOString(),\n\t\t\tretained: false,\n\t\t};\n\t\tevents.push(event(\"EVAL_SANDBOX_ALLOCATED\", { sandboxId: identity.sandboxId }));\n\t\tevents.push(event(\"EVAL_SANDBOX_MATERIALIZED\", { fixtureHash: fixture.fixtureHash }));\n\t\tif (policy.authorizedLauncher)\n\t\t\tevents.push(\n\t\t\t\tevent(\"EVAL_LAUNCHER_IDENTITY\", {\n\t\t\t\t\tlauncher: policy.authorizedLauncher.executableIdentity,\n\t\t\t\t\texecutablePath: policy.authorizedLauncher.executablePath,\n\t\t\t\t\tlauncherId: policy.authorizedLauncher.launcherId,\n\t\t\t\t}),\n\t\t\t);\n\t\tconst diskBytes = await directorySize(root);\n\t\tif (diskBytes > policy.maximumDiskBytes) throw new Error(\"fixture exceeds sandbox disk limit\");\n\t\tconst verifiedHash = sha256(JSON.stringify({ fixtureHash: fixture.fixtureHash, diskBytes }));\n\t\tif (!verifiedHash) throw new Error(\"sandbox verification failed\");\n\t\tevents.push(event(\"EVAL_SANDBOX_VERIFIED\", { fixtureHash: fixture.fixtureHash }));\n\t\treturn {\n\t\t\troot,\n\t\t\tidentity,\n\t\t\tpolicy,\n\t\t\tevents,\n\t\t\trunProcess: (command, args, options) => runProcess(root, policy, events, command, args ?? [], options),\n\t\t\tretain: async () => {\n\t\t\t\tif (retained) return;\n\t\t\t\tretained = true;\n\t\t\t\tidentity.retained = true;\n\t\t\t\tawait makeReadOnly(root);\n\t\t\t\tevents.push(event(\"EVAL_SANDBOX_RETAINED\", { sandboxId: identity.sandboxId }));\n\t\t\t},\n\t\t\tcleanup: async () => {\n\t\t\t\tevents.push(event(\"EVAL_SANDBOX_CLEANUP_STARTED\", { sandboxId: identity.sandboxId }));\n\t\t\t\tif (!retained) await rm(root, { recursive: true, force: true });\n\t\t\t\tevents.push(event(\"EVAL_SANDBOX_CLEANUP_COMPLETED\", { sandboxId: identity.sandboxId }));\n\t\t\t},\n\t\t};\n\t} catch (error) {\n\t\tif (fixture) await cleanupFixture(fixture);\n\t\tthrow error;\n\t}\n}\n\nexport async function writeSandboxMetadata(sandbox: EvaluationSandbox): Promise<void> {\n\tawait mkdir(sandbox.root, { recursive: true });\n\tawait writeFile(resolve(sandbox.root, \".jensen-sandbox.json\"), JSON.stringify(sandbox.identity, null, 2), \"utf8\");\n}\n\nexport async function readSandboxMetadata(root: string): Promise<EvaluationSandboxIdentity> {\n\tconst metadataPath = assertSandboxPath(root, \".jensen-sandbox.json\");\n\treturn JSON.parse(await readFile(metadataPath, \"utf8\")) as EvaluationSandboxIdentity;\n}\n"]}