diff --git a/packages/prompts-core/src/index.ts b/packages/prompts-core/src/index.ts index af788116b..639fa86a7 100644 --- a/packages/prompts-core/src/index.ts +++ b/packages/prompts-core/src/index.ts @@ -8,4 +8,4 @@ export type { } from "./types" export { resolveVariant } from "./variant-resolver" export type { ResolveVariantInput } from "./variant-resolver" -export { loadPrompt, PromptFileNotFoundError } from "./loader" +export { loadPrompt, PromptFileNotFoundError, PromptPathTraversalError } from "./loader" diff --git a/packages/prompts-core/src/loader.test.ts b/packages/prompts-core/src/loader.test.ts index 4017e1969..2842ab640 100644 --- a/packages/prompts-core/src/loader.test.ts +++ b/packages/prompts-core/src/loader.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import { dirname, join } from "node:path" import { fileURLToPath } from "node:url" -import { loadPrompt, PromptFileNotFoundError } from "./loader" +import { loadPrompt, PromptFileNotFoundError, PromptPathTraversalError } from "./loader" import type { PromptSource } from "./types" const fixtureSource: PromptSource = { @@ -50,6 +50,22 @@ describe("loadPrompt", () => { expect(expectError(error).message).toContain("test-prompt/missing") }) + test("#given prompt name escapes source directory #then rejects path traversal", async () => { + const error = await captureError(() => + loadPrompt({ source: fixtureSource, name: "../test-prompt", variant: "default" }) + ) + + expect(error).toBeInstanceOf(PromptPathTraversalError) + }) + + test("#given variant escapes source directory #then rejects path traversal", async () => { + const error = await captureError(() => + loadPrompt({ source: fixtureSource, name: "test-prompt", variant: "../../outside" }) + ) + + expect(error).toBeInstanceOf(PromptPathTraversalError) + }) + test("#given runtime injection #then replaces placeholder in body", async () => { const prompt = await loadPrompt({ source: fixtureSource, diff --git a/packages/prompts-core/src/loader.ts b/packages/prompts-core/src/loader.ts index 6d9a04218..df01b4579 100644 --- a/packages/prompts-core/src/loader.ts +++ b/packages/prompts-core/src/loader.ts @@ -1,6 +1,6 @@ import { parseFrontmatter } from "@oh-my-opencode/utils" import { readFile } from "node:fs/promises" -import { join } from "node:path" +import { isAbsolute, relative, resolve } from "node:path" import type { LoadedPrompt, LoadPromptInput, RuntimeInjection } from "./types" export class PromptFileNotFoundError extends Error { @@ -16,10 +16,21 @@ export class PromptFileNotFoundError extends Error { } } +export class PromptPathTraversalError extends Error { + readonly name = "PromptPathTraversalError" + + constructor( + readonly promptName: string, + readonly variant: string + ) { + super(`Prompt path escapes source directory for ${promptName}/${variant}`) + } +} + export async function loadPrompt>( input: LoadPromptInput ): Promise> { - const filePath = join(input.source.baseDir, input.name, `${input.variant}.md`) + const filePath = resolvePromptFilePath(input.source.baseDir, input.name, input.variant) const content = await readPromptFile(input.name, input.variant, filePath) const parsed = parseFrontmatter(content) const body = await applyRuntimeInjections(parsed.body, input.inject ?? []) @@ -33,6 +44,16 @@ export async function loadPrompt>( } } +function resolvePromptFilePath(baseDir: string, promptName: string, variant: string): string { + const resolvedBaseDir = resolve(baseDir) + const filePath = resolve(resolvedBaseDir, promptName, `${variant}.md`) + const relativePath = relative(resolvedBaseDir, filePath) + if (relativePath.startsWith("..") || isAbsolute(relativePath)) { + throw new PromptPathTraversalError(promptName, variant) + } + return filePath +} + async function readPromptFile(promptName: string, variant: string, filePath: string): Promise { try { return await readFile(filePath, "utf8") diff --git a/packages/prompts-core/src/variant-resolver.ts b/packages/prompts-core/src/variant-resolver.ts index f2f5e1d81..605924c5f 100644 --- a/packages/prompts-core/src/variant-resolver.ts +++ b/packages/prompts-core/src/variant-resolver.ts @@ -8,15 +8,17 @@ import { } from "@oh-my-opencode/model-core" import type { VariantTable } from "./types" +type ModelMatcher = (modelID: string) => boolean + export type ResolveVariantInput = { readonly modelID?: string readonly agentName?: string readonly variants: VariantTable } -const PLANNER_AGENT_NAMES = new Set(["prometheus"]) +const PLANNER_AGENT_NAMES: ReadonlySet = new Set(["prometheus"] as const) -const MODEL_MATCHERS: Record boolean> = { +const MODEL_MATCHERS: Readonly> = { gpt: isGptModel, gemini: isGeminiModel, kimi: isKimiK2Model, @@ -43,10 +45,7 @@ export function resolveVariant(input: ResolveVariantInput): string { if (variantNames.includes("default")) return "default" - const firstVariant = variantNames[0] - if (firstVariant !== undefined) return firstVariant - - throw new TypeError("resolveVariant requires at least one prompt variant") + return variantNames[0] } function isPlannerAgent(agentName: string | undefined): boolean {