diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index e49372ddc41..da67377e34f 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -428,6 +428,26 @@ Common pitfalls: Tool allow/deny policies still apply before sandbox rules. If a tool is denied globally or per-agent, sandboxing doesn’t bring it back. +## Sandboxed research-script preflight + +When a sandboxed `exec` command directly runs a Python or Node script +(`python*.py`, `node *.js`, `node *.mjs`, `node *.cjs`), OpenClaw applies a +deterministic research template before launch. + +- Python sandbox template: only a curated stdlib import set plus workspace-local + modules/packages are allowed. +- Node sandbox template: only a curated `node:` builtin set plus relative + workspace-local files are allowed. +- Unsafe runtime/process/network imports such as `subprocess`, `socket`, + `node:child_process`, `node:http`, and dynamic `require()` / `import()` calls + are blocked with an `exec preflight` error before the command starts. +- Bare third-party dependencies are also blocked unless the template allowlist + is expanded in code. + +This import-template validation is sandbox-only. Host exec keeps the existing +shell-bleed preflight checks, but it does not enforce the research import +template. + `tools.elevated` is an explicit escape hatch that runs `exec` on the host. `/exec` directives only apply for authorized senders and persist per session; to hard-disable `exec`, use tool policy deny (see [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated)). diff --git a/src/agents/bash-tools.exec.script-preflight.test.ts b/src/agents/bash-tools.exec.script-preflight.test.ts index c5544887ad9..23b456acaee 100644 --- a/src/agents/bash-tools.exec.script-preflight.test.ts +++ b/src/agents/bash-tools.exec.script-preflight.test.ts @@ -9,6 +9,62 @@ const isWin = process.platform === "win32"; const describeNonWin = isWin ? describe.skip : describe; describeNonWin("exec script preflight", () => { + it("blocks sandboxed node scripts that import denied runtime modules", async () => { + await withTempDir("openclaw-exec-preflight-sandbox-", async (tmp) => { + const jsPath = path.join(tmp, "bad.js"); + await fs.writeFile( + jsPath, + [ + 'const { execSync } = require("node:child_process");', + "console.log(typeof execSync);", + ].join("\n"), + "utf-8", + ); + + const tool = createExecTool({ + host: "sandbox", + security: "full", + ask: "off", + sandbox: { + containerName: "openclaw-test-sandbox", + workspaceDir: tmp, + containerWorkdir: "/workspace", + }, + }); + + await expect( + tool.execute("call-sandbox-blocked", { + command: "node bad.js", + workdir: tmp, + }), + ).rejects.toThrow(/sandbox template "node-research" blocks import "node:child_process"/); + }); + }); + + it("keeps import-template validation sandbox-only for host exec", async () => { + await withTempDir("openclaw-exec-preflight-host-", async (tmp) => { + const jsPath = path.join(tmp, "host-ok.js"); + await fs.writeFile( + jsPath, + [ + 'const { execSync } = require("node:child_process");', + "console.log(typeof execSync);", + ].join("\n"), + "utf-8", + ); + + const tool = createExecTool({ host: "gateway", security: "full", ask: "off" }); + const result = await tool.execute("call-host-allowed", { + command: "node host-ok.js", + workdir: tmp, + }); + const text = result.content.find((block) => block.type === "text")?.text ?? ""; + + expect(text).toContain("function"); + expect(text).not.toContain('sandbox template "node-research"'); + }); + }); + it("blocks shell env var injection tokens in python scripts before execution", async () => { await withTempDir("openclaw-exec-preflight-", async (tmp) => { const pyPath = path.join(tmp, "bad.py"); diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index dcb50c0344c..bdc879548dc 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -42,6 +42,10 @@ import { resolveWorkdir, truncateMiddle, } from "./bash-tools.shared.js"; +import { + extractSandboxExecutionTargetFromCommand, + validateSandboxExecutionTemplateImports, +} from "./sandbox-execution-templates.js"; import { assertSandboxPath } from "./sandbox-paths.js"; export type { BashSandboxConfig } from "./bash-tools.shared.js"; @@ -51,36 +55,12 @@ export type { ExecToolDetails, } from "./bash-tools.exec-types.js"; -function extractScriptTargetFromCommand( - command: string, -): { kind: "python"; relOrAbsPath: string } | { kind: "node"; relOrAbsPath: string } | null { - const raw = command.trim(); - if (!raw) { - return null; - } - - // Intentionally simple parsing: we only support common forms like - // python file.py - // python3 -u file.py - // node --experimental-something file.js - // If the command is more complex (pipes, heredocs, quoted paths with spaces), skip preflight. - const pythonMatch = raw.match(/^\s*(python3?|python)\s+(?:-[^\s]+\s+)*([^\s]+\.py)\b/i); - if (pythonMatch?.[2]) { - return { kind: "python", relOrAbsPath: pythonMatch[2] }; - } - const nodeMatch = raw.match(/^\s*(node)\s+(?:--[^\s]+\s+)*([^\s]+\.js)\b/i); - if (nodeMatch?.[2]) { - return { kind: "node", relOrAbsPath: nodeMatch[2] }; - } - - return null; -} - -async function validateScriptFileForShellBleed(params: { +async function validateScriptFilePreflight(params: { command: string; workdir: string; + sandboxed: boolean; }): Promise { - const target = extractScriptTargetFromCommand(params.command); + const target = extractSandboxExecutionTargetFromCommand(params.command); if (!target) { return; } @@ -145,6 +125,17 @@ async function validateScriptFileForShellBleed(params: { ); } } + + if (!params.sandboxed) { + return; + } + + validateSandboxExecutionTemplateImports({ + kind: target.kind, + filePath: absPath, + workdir: params.workdir, + content, + }); } export function createExecTool( @@ -501,7 +492,11 @@ export function createExecTool( // Preflight: catch a common model failure mode (shell syntax leaking into Python/JS sources) // before we execute and burn tokens in cron loops. - await validateScriptFileForShellBleed({ command: params.command, workdir }); + await validateScriptFilePreflight({ + command: params.command, + workdir, + sandboxed: Boolean(sandbox), + }); const run = await runExecProcess({ command: params.command, diff --git a/src/agents/sandbox-execution-templates.test.ts b/src/agents/sandbox-execution-templates.test.ts new file mode 100644 index 00000000000..6de32bdfca2 --- /dev/null +++ b/src/agents/sandbox-execution-templates.test.ts @@ -0,0 +1,143 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { withTempDir } from "../test-utils/temp-dir.js"; +import { + extractSandboxExecutionTargetFromCommand, + validateSandboxExecutionTemplateImports, +} from "./sandbox-execution-templates.js"; + +describe("sandbox execution templates", () => { + it("extracts direct python and node script targets from simple exec commands", () => { + expect(extractSandboxExecutionTargetFromCommand("python3 -u scripts/run.py")).toEqual({ + kind: "python", + templateId: "python-research", + relOrAbsPath: "scripts/run.py", + }); + expect(extractSandboxExecutionTargetFromCommand("node --trace-warnings tools/run.mjs")).toEqual( + { + kind: "node", + templateId: "node-research", + relOrAbsPath: "tools/run.mjs", + }, + ); + expect(extractSandboxExecutionTargetFromCommand('node "quoted.js"')).toBeNull(); + }); + + it("allows python stdlib imports and workspace-local helper modules", async () => { + await withTempDir("openclaw-sandbox-template-python-", async (tmp) => { + const mainPath = path.join(tmp, "research.py"); + await fs.writeFile(path.join(tmp, "helpers.py"), "VALUE = 1\n", "utf-8"); + await fs.writeFile( + mainPath, + ["import math", "import helpers", "from collections import Counter", "print(math.pi)"].join( + "\n", + ), + "utf-8", + ); + + const content = await fs.readFile(mainPath, "utf-8"); + expect(() => + validateSandboxExecutionTemplateImports({ + kind: "python", + filePath: mainPath, + workdir: tmp, + content, + }), + ).not.toThrow(); + }); + }); + + it("blocks unsafe python process imports with an actionable sandbox-template error", async () => { + await withTempDir("openclaw-sandbox-template-python-", async (tmp) => { + const mainPath = path.join(tmp, "research.py"); + await fs.writeFile(mainPath, ["import json", "import subprocess"].join("\n"), "utf-8"); + + const content = await fs.readFile(mainPath, "utf-8"); + expect(() => + validateSandboxExecutionTemplateImports({ + kind: "python", + filePath: mainPath, + workdir: tmp, + content, + }), + ).toThrow(/sandbox template "python-research" blocks import "subprocess"/); + }); + }); + + it("blocks non-allowlisted python third-party dependencies", async () => { + await withTempDir("openclaw-sandbox-template-python-", async (tmp) => { + const mainPath = path.join(tmp, "research.py"); + await fs.writeFile(mainPath, "import pandas\n", "utf-8"); + + const content = await fs.readFile(mainPath, "utf-8"); + expect(() => + validateSandboxExecutionTemplateImports({ + kind: "python", + filePath: mainPath, + workdir: tmp, + content, + }), + ).toThrow(/Only deterministic stdlib imports and workspace-local modules are allowed/); + }); + }); + + it("allows node builtins on the template allowlist and relative helper imports", async () => { + await withTempDir("openclaw-sandbox-template-node-", async (tmp) => { + const mainPath = path.join(tmp, "research.mjs"); + await fs.writeFile(path.join(tmp, "helpers.mjs"), "export const value = 1;\n", "utf-8"); + await fs.writeFile( + mainPath, + [ + 'import path from "node:path";', + 'import { value } from "./helpers.mjs";', + "console.log(path.basename(import.meta.url), value);", + ].join("\n"), + "utf-8", + ); + + const content = await fs.readFile(mainPath, "utf-8"); + expect(() => + validateSandboxExecutionTemplateImports({ + kind: "node", + filePath: mainPath, + workdir: tmp, + content, + }), + ).not.toThrow(); + }); + }); + + it("blocks unsafe node runtime dependencies and dynamic require calls", async () => { + await withTempDir("openclaw-sandbox-template-node-", async (tmp) => { + const blockedPath = path.join(tmp, "blocked.mjs"); + await fs.writeFile( + blockedPath, + 'import { execSync } from "node:child_process";\nconsole.log(execSync);\n', + "utf-8", + ); + + const blockedContent = await fs.readFile(blockedPath, "utf-8"); + expect(() => + validateSandboxExecutionTemplateImports({ + kind: "node", + filePath: blockedPath, + workdir: tmp, + content: blockedContent, + }), + ).toThrow(/sandbox template "node-research" blocks import "node:child_process"/); + + const dynamicPath = path.join(tmp, "dynamic.cjs"); + await fs.writeFile(dynamicPath, "const name = process.argv[2];\nrequire(name);\n", "utf-8"); + const dynamicContent = await fs.readFile(dynamicPath, "utf-8"); + expect(() => + validateSandboxExecutionTemplateImports({ + kind: "node", + filePath: dynamicPath, + workdir: tmp, + content: dynamicContent, + }), + ).toThrow(/Dynamic require\(\) calls are not allowed/); + }); + }); +}); diff --git a/src/agents/sandbox-execution-templates.ts b/src/agents/sandbox-execution-templates.ts new file mode 100644 index 00000000000..fa58510e734 --- /dev/null +++ b/src/agents/sandbox-execution-templates.ts @@ -0,0 +1,532 @@ +import fs from "node:fs"; +import path from "node:path"; + +export type SandboxExecutionTemplateId = "python-research" | "node-research"; + +export type SandboxExecutionTarget = { + kind: "python" | "node"; + templateId: SandboxExecutionTemplateId; + relOrAbsPath: string; +}; + +type SandboxExecutionTemplate = { + id: SandboxExecutionTemplateId; + allowedImports: ReadonlySet; + deniedImports: ReadonlySet; + allowedImportsSummary: string; +}; + +const PYTHON_RESEARCH_ALLOWED_IMPORTS = [ + "argparse", + "bisect", + "collections", + "csv", + "dataclasses", + "datetime", + "decimal", + "functools", + "heapq", + "itertools", + "json", + "math", + "pathlib", + "random", + "re", + "statistics", + "string", + "sys", + "textwrap", + "typing", +] as const; + +const PYTHON_RESEARCH_DENIED_IMPORTS = [ + "asyncio", + "ctypes", + "ftplib", + "http", + "importlib", + "marshal", + "multiprocessing", + "os", + "pickle", + "runpy", + "site", + "socket", + "subprocess", + "telnetlib", + "urllib", +] as const; + +const NODE_RESEARCH_ALLOWED_IMPORTS = [ + "node:assert", + "node:assert/strict", + "node:buffer", + "node:events", + "node:fs", + "node:fs/promises", + "node:path", + "node:stream", + "node:stream/promises", + "node:string_decoder", + "node:timers/promises", + "node:url", + "node:util", +] as const; + +const NODE_RESEARCH_DENIED_IMPORTS = [ + "node:child_process", + "node:cluster", + "node:dgram", + "node:dns", + "node:dns/promises", + "node:http", + "node:https", + "node:inspector", + "node:module", + "node:net", + "node:tls", + "node:vm", + "node:worker_threads", +] as const; + +const NODE_BUILTIN_ALIASES = new Map([ + ...NODE_RESEARCH_ALLOWED_IMPORTS.map((name) => [name.slice("node:".length), name] as const), + ...NODE_RESEARCH_DENIED_IMPORTS.map((name) => [name.slice("node:".length), name] as const), +]); + +function createTemplate( + id: SandboxExecutionTemplateId, + allowedImports: readonly string[], + deniedImports: readonly string[], +): SandboxExecutionTemplate { + const allowed = new Set(allowedImports); + return { + id, + allowedImports: allowed, + deniedImports: new Set(deniedImports), + allowedImportsSummary: Array.from(allowed).toSorted().join(", "), + }; +} + +const PYTHON_RESEARCH_TEMPLATE = createTemplate( + "python-research", + PYTHON_RESEARCH_ALLOWED_IMPORTS, + PYTHON_RESEARCH_DENIED_IMPORTS, +); + +const NODE_RESEARCH_TEMPLATE = createTemplate( + "node-research", + NODE_RESEARCH_ALLOWED_IMPORTS, + NODE_RESEARCH_DENIED_IMPORTS, +); + +function resolveTemplate(kind: "python" | "node"): SandboxExecutionTemplate { + return kind === "python" ? PYTHON_RESEARCH_TEMPLATE : NODE_RESEARCH_TEMPLATE; +} + +function stripQuotedCommandPrefix(raw: string): string { + return raw.trim(); +} + +export function extractSandboxExecutionTargetFromCommand( + command: string, +): SandboxExecutionTarget | null { + const raw = stripQuotedCommandPrefix(command); + if (!raw) { + return null; + } + + const pythonMatch = raw.match( + /^\s*(python(?:\d+(?:\.\d+)?)?)\s+(?:-[^\s]+\s+)*([^\s"'`][^\s]*\.py)\b/i, + ); + if (pythonMatch?.[2]) { + return { + kind: "python", + templateId: "python-research", + relOrAbsPath: pythonMatch[2], + }; + } + + const nodeMatch = raw.match( + /^\s*node\s+(?:--[^\s]+(?:=\S+)?\s+|-[^\s]+\s+)*([^\s"'`][^\s]*\.(?:[cm]?js))\b/i, + ); + if (nodeMatch?.[1]) { + return { + kind: "node", + templateId: "node-research", + relOrAbsPath: nodeMatch[1], + }; + } + + return null; +} + +function isWithinDir(candidatePath: string, rootPath: string): boolean { + const relative = path.relative(path.resolve(rootPath), path.resolve(candidatePath)); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function fileExists(candidatePath: string): boolean { + try { + return fs.statSync(candidatePath).isFile(); + } catch { + return false; + } +} + +function directoryExists(candidatePath: string): boolean { + try { + return fs.statSync(candidatePath).isDirectory(); + } catch { + return false; + } +} + +function resolvePythonLocalImport(params: { + specifier: string; + filePath: string; + workdir: string; +}): boolean { + const trimmed = params.specifier.trim(); + if (!trimmed) { + return false; + } + if (trimmed.startsWith(".")) { + return true; + } + + const rootName = trimmed.split(".")[0]?.trim(); + if (!rootName) { + return false; + } + + const candidateRoots = [path.dirname(params.filePath), params.workdir]; + for (const candidateRoot of candidateRoots) { + const fileCandidate = path.resolve(candidateRoot, `${rootName}.py`); + if (isWithinDir(fileCandidate, params.workdir) && fileExists(fileCandidate)) { + return true; + } + const packageDir = path.resolve(candidateRoot, rootName); + const initCandidate = path.join(packageDir, "__init__.py"); + if ( + isWithinDir(packageDir, params.workdir) && + directoryExists(packageDir) && + fileExists(initCandidate) + ) { + return true; + } + } + + return false; +} + +function canonicalizeNodeSpecifier(specifier: string): string { + const trimmed = specifier.trim(); + if (!trimmed) { + return trimmed; + } + if (trimmed.startsWith("node:")) { + return trimmed; + } + return NODE_BUILTIN_ALIASES.get(trimmed) ?? trimmed; +} + +function resolveNodeLocalImport(params: { + specifier: string; + filePath: string; + workdir: string; +}): boolean { + const trimmed = params.specifier.trim(); + if (!(trimmed.startsWith("./") || trimmed.startsWith("../"))) { + return false; + } + + const base = path.resolve(path.dirname(params.filePath), trimmed); + const candidates = [ + base, + `${base}.js`, + `${base}.mjs`, + `${base}.cjs`, + path.join(base, "index.js"), + path.join(base, "index.mjs"), + path.join(base, "index.cjs"), + ]; + + return candidates.some( + (candidate) => isWithinDir(candidate, params.workdir) && fileExists(candidate), + ); +} + +function buildImportError(params: { + template: SandboxExecutionTemplate; + filePath: string; + line: number; + specifier: string; + detail: string; + localHelp: string; +}): Error { + return new Error( + [ + `exec preflight: sandbox template "${params.template.id}" blocks import "${params.specifier}" in ${path.basename( + params.filePath, + )}:${params.line}.`, + params.detail, + `Allowed imports: ${params.template.allowedImportsSummary}.`, + params.localHelp, + ].join("\n"), + ); +} + +function assertPythonImportAllowed(params: { + template: SandboxExecutionTemplate; + specifier: string; + filePath: string; + workdir: string; + line: number; +}): void { + const rootSpecifier = params.specifier.trim().replace(/^\.+/, "").split(".")[0]?.trim(); + if (!rootSpecifier) { + return; + } + if (params.template.deniedImports.has(rootSpecifier)) { + throw buildImportError({ + template: params.template, + filePath: params.filePath, + line: params.line, + specifier: rootSpecifier, + detail: + "Unsafe stdlib/process/network imports are not allowed inside the sandbox research template.", + localHelp: + "Allowed local imports: relative imports or workspace-local modules/packages that resolve inside the sandbox workdir.", + }); + } + if (params.template.allowedImports.has(rootSpecifier)) { + return; + } + if ( + resolvePythonLocalImport({ + specifier: params.specifier, + filePath: params.filePath, + workdir: params.workdir, + }) + ) { + return; + } + throw buildImportError({ + template: params.template, + filePath: params.filePath, + line: params.line, + specifier: rootSpecifier, + detail: + "Only deterministic stdlib imports and workspace-local modules are allowed for sandboxed Python research scripts.", + localHelp: + "Third-party packages and non-local bare imports are denied unless the template allowlist is expanded in code.", + }); +} + +function validatePythonImports(params: { + template: SandboxExecutionTemplate; + filePath: string; + workdir: string; + content: string; +}): void { + const lines = params.content.split(/\r?\n/); + for (const [index, rawLine] of lines.entries()) { + const line = rawLine.replace(/#.*$/, ""); + if (!line.trim()) { + continue; + } + if (/\b__import__\s*\(/.test(line)) { + throw buildImportError({ + template: params.template, + filePath: params.filePath, + line: index + 1, + specifier: "__import__", + detail: "Dynamic Python imports are not allowed inside sandbox research templates.", + localHelp: + "Use explicit import statements so the sandbox validator can prove which modules are used.", + }); + } + + const importMatch = line.match(/^\s*import\s+(.+)$/); + if (importMatch?.[1]) { + const specifiers = importMatch[1] + .split(",") + .map((part) => + part + .trim() + .split(/\s+as\s+/i)[0] + ?.trim(), + ) + .filter((part): part is string => Boolean(part)); + for (const specifier of specifiers) { + assertPythonImportAllowed({ + template: params.template, + specifier, + filePath: params.filePath, + workdir: params.workdir, + line: index + 1, + }); + } + continue; + } + + const fromMatch = line.match(/^\s*from\s+([.\w]+)\s+import\s+/); + if (fromMatch?.[1]) { + assertPythonImportAllowed({ + template: params.template, + specifier: fromMatch[1], + filePath: params.filePath, + workdir: params.workdir, + line: index + 1, + }); + } + } +} + +function assertNodeImportAllowed(params: { + template: SandboxExecutionTemplate; + specifier: string; + filePath: string; + workdir: string; + line: number; +}): void { + const canonical = canonicalizeNodeSpecifier(params.specifier); + if (params.template.deniedImports.has(canonical)) { + throw buildImportError({ + template: params.template, + filePath: params.filePath, + line: params.line, + specifier: canonical, + detail: + "Process, network, VM, and dynamic module-control imports are blocked in the sandbox Node research template.", + localHelp: "Allowed local imports: relative files that resolve inside the sandbox workdir.", + }); + } + if (params.template.allowedImports.has(canonical)) { + return; + } + if ( + resolveNodeLocalImport({ + specifier: params.specifier, + filePath: params.filePath, + workdir: params.workdir, + }) + ) { + return; + } + throw buildImportError({ + template: params.template, + filePath: params.filePath, + line: params.line, + specifier: params.specifier, + detail: + "Only allowed `node:` builtins and relative workspace-local files are permitted for sandboxed Node research scripts.", + localHelp: + "Bare external packages are denied unless the template allowlist is expanded in code.", + }); +} + +function validateNodeImports(params: { + template: SandboxExecutionTemplate; + filePath: string; + workdir: string; + content: string; +}): void { + const literalPatterns = [ + /\bimport\s+(?:type\s+)?(?:[^"'`]+\s+from\s+)?["']([^"']+)["']/g, + /\bexport\s+[^"'`]+\s+from\s+["']([^"']+)["']/g, + /\brequire\(\s*["']([^"']+)["']\s*\)/g, + /\bimport\(\s*["']([^"']+)["']\s*\)/g, + ]; + const lines = params.content.split(/\r?\n/); + for (const [index, rawLine] of lines.entries()) { + const trimmed = rawLine.trim(); + if ( + !trimmed || + trimmed.startsWith("//") || + trimmed.startsWith("/*") || + trimmed.startsWith("*") + ) { + continue; + } + + let foundLiteralImport = false; + for (const pattern of literalPatterns) { + pattern.lastIndex = 0; + for (const match of trimmed.matchAll(pattern)) { + const specifier = match[1]?.trim(); + if (!specifier) { + continue; + } + foundLiteralImport = true; + assertNodeImportAllowed({ + template: params.template, + specifier, + filePath: params.filePath, + workdir: params.workdir, + line: index + 1, + }); + } + } + + if (/\brequire\(/.test(trimmed) && !/\brequire\(\s*["'][^"']+["']\s*\)/.test(trimmed)) { + throw buildImportError({ + template: params.template, + filePath: params.filePath, + line: index + 1, + specifier: "require(...)", + detail: "Dynamic require() calls are not allowed inside sandbox Node research templates.", + localHelp: + "Use explicit string-literal imports so the validator can prove which dependencies are used.", + }); + } + if (/\bimport\(/.test(trimmed) && !/\bimport\(\s*["'][^"']+["']\s*\)/.test(trimmed)) { + throw buildImportError({ + template: params.template, + filePath: params.filePath, + line: index + 1, + specifier: "import(...)", + detail: "Dynamic import() calls are not allowed inside sandbox Node research templates.", + localHelp: + "Use explicit static imports or literal dynamic imports that stay inside the allowlist.", + }); + } + + if (!foundLiteralImport && /\bexport\s+[^"'`]+\s+from\s+/.test(trimmed)) { + throw buildImportError({ + template: params.template, + filePath: params.filePath, + line: index + 1, + specifier: "export ... from", + detail: "Re-export specifiers must use explicit string literals.", + localHelp: + "Use a normal string-literal module specifier so the sandbox validator can evaluate it.", + }); + } + } +} + +export function validateSandboxExecutionTemplateImports(params: { + kind: "python" | "node"; + filePath: string; + workdir: string; + content: string; +}): void { + const template = resolveTemplate(params.kind); + if (params.kind === "python") { + validatePythonImports({ + template, + filePath: params.filePath, + workdir: params.workdir, + content: params.content, + }); + return; + } + validateNodeImports({ + template, + filePath: params.filePath, + workdir: params.workdir, + content: params.content, + }); +}