From d5e9bf5b011920240a87d101b1d3d4697b2e2e62 Mon Sep 17 00:00:00 2001 From: sunlei Date: Wed, 24 Jun 2026 23:47:19 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E7=B4=A7NapCat=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E8=AF=8D=E5=8A=A0=E8=BD=BD=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/selfTest.ts | 12 +++++++++ src/tools/napcatAutomation.prompts.ts | 38 +++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) 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'); }