diff --git a/src/selfTest.ts b/src/selfTest.ts index d7f653e..915048f 100644 --- a/src/selfTest.ts +++ b/src/selfTest.ts @@ -42,6 +42,7 @@ import { } from "./tools/napcatAutomation.types.js"; import { loadNapcatAutomationPrompt, + type NapcatAutomationPromptName, napcatAutomationPromptNames, } from "./tools/napcatAutomation.prompts.js"; import { @@ -99,6 +100,17 @@ export async function runSelfTest(): Promise { throw new Error(`NapCat prompt safety contract missing: ${promptName}`); } } + let traversalPromptRejected = false; + try { + loadNapcatAutomationPrompt("../../README" as NapcatAutomationPromptName); + } catch (error) { + traversalPromptRejected = String(error).includes( + "Unsupported NapCat automation prompt", + ); + } + if (!traversalPromptRejected) { + throw new Error("NapCat prompt traversal self-check failed"); + } const reviewClassifier = { localizedSecretLabelAllowed: isBenignCredentialReviewValue("密钥"), diff --git a/src/tools/napcatAutomation.prompts.ts b/src/tools/napcatAutomation.prompts.ts index d6b1179..2438b0f 100644 --- a/src/tools/napcatAutomation.prompts.ts +++ b/src/tools/napcatAutomation.prompts.ts @@ -17,14 +17,48 @@ const promptsRoot = path.resolve( 'prompts', 'napcat', ); +const promptsRootBoundary = promptsRoot.endsWith(path.sep) + ? promptsRoot + : `${promptsRoot}${path.sep}`; +const napcatAutomationPromptNameSet = new Set( + napcatAutomationPromptNames, +); +const napcatAutomationPromptFileNames = { + 'upstream-audit': 'upstream-audit.md', + 'sync-candidate-review': 'sync-candidate-review.md', + 'runtime-release-readiness': 'runtime-release-readiness.md', + 'remote-dev-handoff': 'remote-dev-handoff.md', +} satisfies Record; + +/** + * Parses an external prompt name into the source-controlled NapCat prompt enum. + * @param value - Raw prompt name from CLI, JSON, MCP input, or a typed caller; only exact entries in `napcatAutomationPromptNames` are accepted. + * @returns A stable prompt name that can be mapped to a fixed file under `prompts/napcat`. + */ +export function parseNapcatAutomationPromptName( + value: string, +): NapcatAutomationPromptName { + if (napcatAutomationPromptNameSet.has(value)) { + return value as NapcatAutomationPromptName; + } + throw new Error(`Unsupported NapCat automation prompt: ${value}`); +} /** * Loads a source-controlled NapCat automation prompt by stable prompt name. - * @param name - Prompt identifier from `napcatAutomationPromptNames`; because it is an enum value rather than a user-supplied path, callers cannot escape `prompts/napcat`. + * @param name - Prompt identifier from `napcatAutomationPromptNames`; runtime callers may still pass untyped external strings, so the loader re-validates and resolves only fixed filenames. * @returns UTF-8 prompt text used by Codex CLI automation. */ export function loadNapcatAutomationPrompt( name: NapcatAutomationPromptName, ): string { - return readFileSync(path.join(promptsRoot, `${name}.md`), 'utf8'); + const promptName = parseNapcatAutomationPromptName(String(name)); + const promptPath = path.resolve( + promptsRoot, + napcatAutomationPromptFileNames[promptName], + ); + if (!promptPath.startsWith(promptsRootBoundary)) { + throw new Error(`NapCat automation prompt escaped prompt root: ${name}`); + } + return readFileSync(promptPath, 'utf8'); }