From f71cde84b88dcbd3d943408bd18fe53b812530a5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:58:42 +0000 Subject: [PATCH] fix: split config-eval from config-rendering for browser safety Move Node.js-only functions (evalConfigFileContent, tryEvalConfigFileContent, detectImportPackageFromDir) to new config-eval.ts. This prevents the dashboard browser build from failing on fs/path/jiti imports. Dashboard now uses parseStaticConfigLiteral (regex+JSON.parse) instead of jiti eval for untrusted GitHub-fetched config content, avoiding RCE risk. Remove type casts in favor of isRecord type guard. Co-Authored-By: mantra --- apps/backend/src/lib/local-emulator.ts | 3 +- apps/dashboard/src/lib/github-config-push.ts | 9 +- packages/cli/src/commands/config-file.ts | 2 +- packages/cli/src/commands/init.ts | 3 +- packages/shared-backend/src/index.ts | 9 +- packages/shared/src/config-eval.ts | 147 +++++++++++++++++++ packages/shared/src/config-rendering.ts | 142 +++++------------- 7 files changed, 200 insertions(+), 115 deletions(-) create mode 100644 packages/shared/src/config-eval.ts diff --git a/apps/backend/src/lib/local-emulator.ts b/apps/backend/src/lib/local-emulator.ts index 664999896..ec144ab6f 100644 --- a/apps/backend/src/lib/local-emulator.ts +++ b/apps/backend/src/lib/local-emulator.ts @@ -1,6 +1,7 @@ import { globalPrismaClient } from "@/prisma-client"; import { showOnboardingHexclaveConfigValue } from "@hexclave/shared/dist/config-authoring"; -import { detectImportPackageFromDir, evalConfigFileContent, renderConfigFileContent } from "@hexclave/shared/dist/config-rendering"; +import { renderConfigFileContent } from "@hexclave/shared/dist/config-rendering"; +import { detectImportPackageFromDir, evalConfigFileContent } from "@hexclave/shared/dist/config-eval"; import { isValidConfig } from "@hexclave/shared/dist/config/format"; import { LOCAL_EMULATOR_ADMIN_EMAIL, LOCAL_EMULATOR_ADMIN_PASSWORD } from "@hexclave/shared/dist/local-emulator"; import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; diff --git a/apps/dashboard/src/lib/github-config-push.ts b/apps/dashboard/src/lib/github-config-push.ts index 5fa5d1080..d64c59eb8 100644 --- a/apps/dashboard/src/lib/github-config-push.ts +++ b/apps/dashboard/src/lib/github-config-push.ts @@ -12,8 +12,7 @@ import type { PushedConfigSource } from "@hexclave/next"; import type { EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/config/schema"; import { isValidConfig, override } from "@hexclave/shared/dist/config/format"; -import { evalConfigFileContent, renderConfigFileContent } from "@hexclave/shared/dist/config-rendering"; -import { showOnboardingHexclaveConfigValue } from "@hexclave/shared/dist/hexclave-config-file"; +import { parseStaticConfigLiteral, renderConfigFileContent } from "@hexclave/shared/dist/config-rendering"; import { commitFile, @@ -52,10 +51,10 @@ export function buildUpdatedConfigFileContent( currentFileContent: string, configUpdate: EnvironmentConfigOverrideOverride, ): string { - const parsed = evalConfigFileContent(currentFileContent, "stack.config.ts"); - if (parsed === showOnboardingHexclaveConfigValue) { + const parsed = parseStaticConfigLiteral(currentFileContent); + if (parsed == null) { throw new Error( - "The config file currently exports the onboarding placeholder. Finish setting up Hexclave in your repo before pushing dashboard changes." + "Could not parse the existing config file. The file must be a static JSON config object (as produced by the dashboard). Config files with dynamic expressions or imports are not supported for dashboard-driven updates." ); } if (!isValidConfig(parsed)) { diff --git a/packages/cli/src/commands/config-file.ts b/packages/cli/src/commands/config-file.ts index 844c9b481..a2676ed01 100644 --- a/packages/cli/src/commands/config-file.ts +++ b/packages/cli/src/commands/config-file.ts @@ -1,5 +1,5 @@ import { replaceConfigObject, updateConfigObject } from "@hexclave/shared-backend"; -import { detectImportPackageFromDir } from "@hexclave/shared/dist/config-rendering"; +import { detectImportPackageFromDir } from "@hexclave/shared/dist/config-eval"; import { isValidConfig } from "@hexclave/shared/dist/config/format"; import type { EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/config/schema"; import { throwErr } from "@hexclave/shared/dist/utils/errors"; diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 5ca5421df..614b7e893 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -13,7 +13,8 @@ import { createInitPrompt } from "../lib/init-prompt.js"; import { createProjectInteractively } from "../lib/create-project.js"; import { runClaudeAgent } from "../lib/claude-agent.js"; import { resolveConfigFilePathOption } from "../lib/config-file-path.js"; -import { detectImportPackageFromDir, renderConfigFileContent } from "@hexclave/shared/dist/config-rendering"; +import { renderConfigFileContent } from "@hexclave/shared/dist/config-rendering"; +import { detectImportPackageFromDir } from "@hexclave/shared/dist/config-eval"; import { throwErr } from "@hexclave/shared/dist/utils/errors"; const VALID_INIT_MODES = ["create", "create-cloud", "link-config", "link-cloud"] as const; diff --git a/packages/shared-backend/src/index.ts b/packages/shared-backend/src/index.ts index f336fd5ae..d430ae434 100644 --- a/packages/shared-backend/src/index.ts +++ b/packages/shared-backend/src/index.ts @@ -1,5 +1,6 @@ import { showOnboardingHexclaveConfigValue } from "@hexclave/shared/dist/config-authoring"; -import { detectImportPackageFromDir, evalConfigFileContent, renderConfigFileContent } from "@hexclave/shared/dist/config-rendering"; +import { renderConfigFileContent } from "@hexclave/shared/dist/config-rendering"; +import { detectImportPackageFromDir, evalConfigFileContent } from "@hexclave/shared/dist/config-eval"; import type { Config, ConfigValue, NormalizedConfig } from "@hexclave/shared/dist/config/format"; import { isValidConfig, normalize, override } from "@hexclave/shared/dist/config/format"; import { captureError } from "@hexclave/shared/dist/utils/errors"; @@ -130,7 +131,7 @@ export async function updateConfigObject(configFilePath: string, configUpdate: C const content = readFileSync(configFilePath, "utf-8"); - // Fast path: if the config is a plain static literal (no imports, no helpers), + // Fast path: if the config can be evaluated by jiti (no unresolvable imports), // apply the update deterministically without invoking the AI agent. const staticConfig = tryParseConfigFileContent(content, configFilePath); if (staticConfig != null && isValidConfig(staticConfig)) { @@ -318,6 +319,10 @@ function configFileExportsConfig(content: string, configFilePath: string): boole evalConfigFileContent(content, configFilePath); return true; } catch { + // jiti may fail to resolve imports that are valid in the user's project but + // absent from the current process (e.g. relative asset imports, workspace + // packages). For the structural sanity check we only need to know a runtime + // `config` binding still exists after the agent edited the file. return /\bexport\s+const\s+config\b/.test(content); } } diff --git a/packages/shared/src/config-eval.ts b/packages/shared/src/config-eval.ts new file mode 100644 index 000000000..6f9e73cc0 --- /dev/null +++ b/packages/shared/src/config-eval.ts @@ -0,0 +1,147 @@ +import { existsSync, readFileSync } from "fs"; +import { createJiti } from "jiti"; +import path from "path"; + +export { hexclaveConfigFileExportsConfig } from "./hexclave-config-file"; + +const jiti = createJiti(import.meta.url, { moduleCache: false }); + +/** + * Packages that export the `HexclaveConfig` type, in priority order. + * The first match found in a project's dependencies wins. Hexclave-branded + * packages come first (canonical); the legacy `@stackframe/*` names remain + * so projects pinned to the last legacy release still render a config file + * that compiles against their installed SDK. + */ +const CONFIG_IMPORT_PACKAGES = [ + "@hexclave/next", + "@hexclave/react", + "@hexclave/tanstack-start", + "@hexclave/js", + "@hexclave/template", + "@stackframe/stack", + "@stackframe/react", + "@stackframe/js", + "@stackframe/template", +] as const; + +/** + * Walks up from `dir` to find the nearest `package.json` and returns the + * best SDK package to use for the `HexclaveConfig` type import. + */ +export function detectImportPackageFromDir(dir: string): string | undefined { + let current = dir; + while (true) { + const pkgPath = path.join(current, "package.json"); + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + const deps = [ + ...Object.keys(pkg.dependencies ?? {}), + ...Object.keys(pkg.devDependencies ?? {}), + ]; + for (const known of CONFIG_IMPORT_PACKAGES) { + if (deps.includes(known)) { + return known; + } + } + return undefined; + } catch { + return undefined; + } + } + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + return undefined; +} + +function isRecord(value: unknown): value is Record { + return value != null && typeof value === "object" && !Array.isArray(value); +} + +type ParsedConfigValue = Record | string; + +/** + * Evaluates config file content using jiti and returns the exported `config` + * value. Replaces the old Babel AST-based `parseHexclaveConfigFileContent`. + * + * WARNING: This executes arbitrary code via `jiti.evalModule` — only use on + * content that is fully operator-controlled (local filesystem). Never call + * this on untrusted input (e.g. content fetched from a remote repository). + */ +export function evalConfigFileContent(content: string, filePath: string): ParsedConfigValue { + if (content.trim() === "") return {}; + const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath); + const mod: unknown = jiti.evalModule(content, { filename: resolvedPath }); + if (!isRecord(mod)) { + throw new Error(`Invalid config in ${filePath}. The file must export a plain \`config\` object or "show-onboarding".`); + } + const config = mod.config; + if (config === undefined) { + throw new Error(`Invalid config in ${filePath}. The file must export a plain \`config\` object or "show-onboarding".`); + } + if (typeof config === "string") return config; + if (isRecord(config)) return config; + throw new Error(`Invalid config in ${filePath}. The file must export a plain \`config\` object or "show-onboarding".`); +} + +/** + * Like {@link evalConfigFileContent}, but returns `null` instead of throwing + * when the content cannot be evaluated. + */ +export function tryEvalConfigFileContent(content: string, filePath: string): ParsedConfigValue | null { + try { + return evalConfigFileContent(content, filePath); + } catch { + return null; + } +} + +// --- inline vitest tests --- + +import.meta.vitest?.test("evalConfigFileContent parses static config exports", ({ expect }) => { + expect(evalConfigFileContent(` + import type { StackConfig } from "@hexclave/js"; + export const config: StackConfig = { + auth: { allowSignUp: true }, + payments: { testMode: false }, + }; + `, "stack.config.ts")).toMatchInlineSnapshot(` + { + "auth": { + "allowSignUp": true, + }, + "payments": { + "testMode": false, + }, + } + `); +}); + +import.meta.vitest?.test("evalConfigFileContent parses show-onboarding", ({ expect }) => { + expect(evalConfigFileContent('export const config = "show-onboarding";', "stack.config.ts")).toBe("show-onboarding"); +}); + +import.meta.vitest?.test("evalConfigFileContent rejects content without config export", ({ expect }) => { + expect(() => evalConfigFileContent("export const other = {};", "stack.config.ts")).toThrow(/must export/); +}); + +import.meta.vitest?.test("tryEvalConfigFileContent returns the config for valid exports", ({ expect }) => { + expect(tryEvalConfigFileContent("export const config = { auth: { allowSignUp: true } };", "stack.config.ts")).toEqual({ + auth: { allowSignUp: true }, + }); +}); + +import.meta.vitest?.test("tryEvalConfigFileContent returns null on unresolvable function call", ({ expect }) => { + expect(tryEvalConfigFileContent("export const config = someUndefinedFunction();", "stack.config.ts")).toBeNull(); +}); + +import.meta.vitest?.test("tryEvalConfigFileContent returns null on unresolvable import", ({ expect }) => { + expect(tryEvalConfigFileContent('import x from "./nonexistent-file";\nexport const config = { a: x };', "stack.config.ts")).toBeNull(); +}); + +import.meta.vitest?.test("tryEvalConfigFileContent returns null on syntax error", ({ expect }) => { + expect(tryEvalConfigFileContent("export const config = {", "stack.config.ts")).toBeNull(); +}); diff --git a/packages/shared/src/config-rendering.ts b/packages/shared/src/config-rendering.ts index 137b81988..e4bb60209 100644 --- a/packages/shared/src/config-rendering.ts +++ b/packages/shared/src/config-rendering.ts @@ -1,14 +1,7 @@ -import { existsSync, readFileSync } from "fs"; -import { createJiti } from "jiti"; -import path from "path"; import { isValidConfig, normalize } from "./config/format"; -import { hexclaveConfigFileExportsConfig } from "./hexclave-config-file"; -export { hexclaveConfigFileExportsConfig }; const DEFAULT_CONFIG_IMPORT_PACKAGE = "@hexclave/js"; -const jiti = createJiti(import.meta.url, { moduleCache: false }); - /** * Packages that export the `HexclaveConfig` type, in priority order. * The first match found in a project's dependencies wins. Hexclave-branded @@ -42,33 +35,6 @@ export function detectConfigImportPackage(dependencies: string[]): string | unde return undefined; } -/** - * Walks up from `dir` to find the nearest `package.json` and returns the - * best SDK package to use for the `HexclaveConfig` type import. - */ -export function detectImportPackageFromDir(dir: string): string | undefined { - let current = dir; - while (true) { - const pkgPath = path.join(current, "package.json"); - if (existsSync(pkgPath)) { - try { - const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); - const deps = [ - ...Object.keys(pkg.dependencies ?? {}), - ...Object.keys(pkg.devDependencies ?? {}), - ]; - return detectConfigImportPackage(deps); - } catch { - return undefined; - } - } - const parent = path.dirname(current); - if (parent === current) break; - current = parent; - } - return undefined; -} - /** * Renders a config object into the source text of a `stack.config.ts` file. */ @@ -92,39 +58,38 @@ export function renderConfigFileContent(config: unknown, importPackage?: string) return `${importLine}\n\nexport const config: HexclaveConfig = ${JSON.stringify(normalizedConfig, null, 2)};\n`; } -type ParsedConfigValue = Record | string; - -/** - * Evaluates config file content using jiti and returns the exported `config` - * value. Replaces the old Babel AST-based `parseHexclaveConfigFileContent`. - */ -export function evalConfigFileContent(content: string, filePath: string): ParsedConfigValue { - if (content.trim() === "") return {}; - const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath); - const mod = jiti.evalModule(content, { filename: resolvedPath }) as Record; - const config = mod.config; - if (config === undefined) { - throw new Error(`Invalid config in ${filePath}. The file must export a plain \`config\` object or "show-onboarding".`); - } - if (typeof config === "string") return config; - if (config !== null && typeof config === "object" && !Array.isArray(config)) { - return config as Record; - } - throw new Error(`Invalid config in ${filePath}. The file must export a plain \`config\` object or "show-onboarding".`); +function isRecord(value: unknown): value is Record { + return value != null && typeof value === "object" && !Array.isArray(value); } /** - * Like {@link evalConfigFileContent}, but returns `null` instead of throwing - * when the content cannot be evaluated. + * Parses a config file that was produced by {@link renderConfigFileContent}. + * Extracts the JSON object literal after the `export const config` assignment + * and parses it with `JSON.parse`. This is intentionally limited to the exact + * format we emit — it never executes code, so it is safe for untrusted input + * such as content fetched from a remote repository. + * + * Returns `null` when the content does not match the expected static format. */ -export function tryEvalConfigFileContent(content: string, filePath: string): ParsedConfigValue | null { +export function parseStaticConfigLiteral(content: string): Record | null { + // Match `export const config ... = ;` — the type annotation is optional. + const match = content.match(/export\s+const\s+config(?:\s*:\s*\w+)?\s*=\s*([\s\S]+);?\s*$/m); + if (match == null) return null; + + // Trim any trailing semicolons and whitespace from the captured JSON body. + const jsonBody = match[1].replace(/;\s*$/, "").trim(); + try { - return evalConfigFileContent(content, filePath); + const parsed: unknown = JSON.parse(jsonBody); + if (isRecord(parsed)) return parsed; + return null; } catch { return null; } } +// --- inline vitest tests --- + import.meta.vitest?.test("renderConfigFileContent normalizes config exports", ({ expect }) => { expect(renderConfigFileContent({ "payments.items.todos.displayName": "Todo Slots", @@ -141,50 +106,6 @@ import.meta.vitest?.test("renderConfigFileContent normalizes config exports", ({ };`); }); -import.meta.vitest?.test("evalConfigFileContent parses static config exports", ({ expect }) => { - expect(evalConfigFileContent(` - import type { StackConfig } from "@hexclave/js"; - export const config: StackConfig = { - auth: { allowSignUp: true }, - payments: { testMode: false }, - }; - `, "stack.config.ts")).toMatchInlineSnapshot(` - { - "auth": { - "allowSignUp": true, - }, - "payments": { - "testMode": false, - }, - } - `); -}); - -import.meta.vitest?.test("evalConfigFileContent parses show-onboarding", ({ expect }) => { - expect(evalConfigFileContent('export const config = "show-onboarding";', "stack.config.ts")).toBe("show-onboarding"); -}); - -import.meta.vitest?.test("evalConfigFileContent rejects content without config export", ({ expect }) => { - expect(() => evalConfigFileContent("export const other = {};", "stack.config.ts")).toThrow(/must export/); -}); - -import.meta.vitest?.test("tryEvalConfigFileContent returns the config for valid exports", ({ expect }) => { - expect(tryEvalConfigFileContent("export const config = { auth: { allowSignUp: true } };", "stack.config.ts")).toEqual({ - auth: { allowSignUp: true }, - }); -}); - -import.meta.vitest?.test("tryEvalConfigFileContent returns null on failure", ({ expect }) => { - expect(tryEvalConfigFileContent("export const config = {", "stack.config.ts")).toBeNull(); -}); - -import.meta.vitest?.test("hexclaveConfigFileExportsConfig detects a config export", ({ expect }) => { - expect(hexclaveConfigFileExportsConfig("export const config = { a: 1 };", "stack.config.ts")).toBe(true); - expect(hexclaveConfigFileExportsConfig('import x from "./x.txt" with { type: "text" };\nexport const config = { a: x };', "stack.config.ts")).toBe(true); - expect(hexclaveConfigFileExportsConfig("export const notConfig = { a: 1 };", "stack.config.ts")).toBe(false); - expect(hexclaveConfigFileExportsConfig("export const config = {", "stack.config.ts")).toBe(false); -}); - import.meta.vitest?.test("renderConfigFileContent rejects conflicting dotted keys", ({ expect }) => { expect(() => renderConfigFileContent({ "a.b": 1, @@ -209,8 +130,6 @@ import.meta.vitest?.test("renderConfigFileContent defaults to @hexclave/js", ({ }); import.meta.vitest?.test("renderConfigFileContent keeps legacy @stackframe packages on their root entrypoint", ({ expect }) => { - // The lightweight `/config` subpath only exists on Hexclave-branded packages; - // already-published @stackframe/* releases predate it. const content = renderConfigFileContent({}, "@stackframe/next"); expect(content).toContain('import type { HexclaveConfig } from "@stackframe/next";'); }); @@ -220,11 +139,24 @@ import.meta.vitest?.test("detectConfigImportPackage picks first matching package expect(detectConfigImportPackage(["@hexclave/react", "@hexclave/js"])).toBe("@hexclave/react"); expect(detectConfigImportPackage(["@hexclave/js"])).toBe("@hexclave/js"); expect(detectConfigImportPackage(["@hexclave/tanstack-start"])).toBe("@hexclave/tanstack-start"); - // Hexclave names take priority over legacy stackframe names when both appear. expect(detectConfigImportPackage(["@stackframe/stack", "@hexclave/next"])).toBe("@hexclave/next"); - // Legacy fallback still works for projects pinned to the last @stackframe/* release. expect(detectConfigImportPackage(["@stackframe/stack"])).toBe("@stackframe/stack"); expect(detectConfigImportPackage(["@stackframe/template"])).toBe("@stackframe/template"); expect(detectConfigImportPackage(["lodash", "express"])).toBeUndefined(); expect(detectConfigImportPackage([])).toBeUndefined(); }); + +import.meta.vitest?.test("parseStaticConfigLiteral extracts JSON from rendered config", ({ expect }) => { + const rendered = renderConfigFileContent({ auth: { allowSignUp: true } }); + expect(parseStaticConfigLiteral(rendered)).toEqual({ auth: { allowSignUp: true } }); +}); + +import.meta.vitest?.test("parseStaticConfigLiteral returns null for non-static content", ({ expect }) => { + expect(parseStaticConfigLiteral("export const config = someFunction();")).toBeNull(); + expect(parseStaticConfigLiteral("export const other = {};")).toBeNull(); + expect(parseStaticConfigLiteral("")).toBeNull(); +}); + +import.meta.vitest?.test("parseStaticConfigLiteral returns null for show-onboarding string", ({ expect }) => { + expect(parseStaticConfigLiteral('export const config = "show-onboarding";')).toBeNull(); +});