diff --git a/src/selfTest.ts b/src/selfTest.ts index 33ef5dd..e62d7d1 100644 --- a/src/selfTest.ts +++ b/src/selfTest.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; import { @@ -132,6 +132,12 @@ export async function runSelfTest(): Promise { if (!codexCommand.includes("--ask-for-approval never")) { throw new Error("NapCat Codex runner approval policy self-check failed"); } + if (!codexCommand.includes("codex --ask-for-approval never exec")) { + throw new Error("NapCat Codex runner top-level approval order self-check failed"); + } + if (codexCommand.includes("codex exec") || codexCommand.includes("$(cat")) { + throw new Error("NapCat Codex runner stdin safety self-check failed"); + } for (const expected of [ "CODEX_HOME='/vol1/docker/kt-codex/home/.codex'", "--cd '/vol1/docker/kt-codex/workspace/KT'", @@ -141,7 +147,7 @@ export async function runSelfTest(): Promise { "--ephemeral", "--output-schema '/vol1/docker/kt-codex/workspace/KT/mcp/ktWorkflow/prompts/napcat/schemas/upstream-audit.schema.json'", "--output-last-message '/vol1/docker/kt-codex/artifacts/smoke/codex-upstream-audit.md'", - "\"$(cat '/vol1/docker/kt-codex/artifacts/smoke/context-packet.md')\"", + "- < '/vol1/docker/kt-codex/artifacts/smoke/context-packet.md'", ]) { if (!codexCommand.includes(expected)) { throw new Error(`NapCat Codex runner command missing: ${expected}`); @@ -157,7 +163,7 @@ export async function runSelfTest(): Promise { artifactRoot: codexExecutionArtifactRoot, codexExecutor: () => ({ ok: true, - stderr: "", + stderr: "schema warning", stdout: "{\"classification\":\"surprising\"}\n", }), dryMergeConflict: false, @@ -176,10 +182,100 @@ export async function runSelfTest(): Promise { !existsSync( path.join(codexExecutionArtifactRoot, "codex-upstream-audit.jsonl"), ) || - !existsSync(path.join(codexExecutionArtifactRoot, "context-packet.md")) + !existsSync(path.join(codexExecutionArtifactRoot, "context-packet.md")) || + readFileSync( + path.join(codexExecutionArtifactRoot, "codex-upstream-audit.stderr.log"), + "utf8", + ) !== "schema warning" ) { throw new Error("NapCat Codex artifact capture self-check failed"); } + const codexItemTextArtifactRoot = resolveInsideRoot( + ".kt-workspace/test-artifacts/napcat-codex-runner-success-self-test", + ); + const codexItemTextAudit = buildNapcatUpstreamAudit({ + artifactRoot: codexItemTextArtifactRoot, + codexExecutor: () => ({ + ok: true, + stderr: "", + stdout: `${JSON.stringify({ + item: { + text: JSON.stringify({ + classification: "safe-candidate", + reasonCodes: ["NO_UPSTREAM_CHANGE"], + recommendedAction: "create-candidate-branch", + summary: "ok", + }), + type: "agent_message", + }, + type: "item.completed", + })}\n`, + }), + dryMergeConflict: false, + execute: true, + forkPatchFiles: [], + upstreamChangedFiles: [], + useCodex: true, + }); + if ( + codexItemTextAudit.classification !== "safe-candidate" || + !codexItemTextAudit.reasonCodes.includes("NO_UPSTREAM_CHANGE") || + codexItemTextAudit.recommendedAction !== "create-candidate-branch" + ) { + throw new Error("NapCat Codex item.text JSONL success self-check failed"); + } + const codexThrowArtifactRoot = resolveInsideRoot( + ".kt-workspace/test-artifacts/napcat-codex-runner-throw-self-test", + ); + const codexThrowAudit = buildNapcatUpstreamAudit({ + artifactRoot: codexThrowArtifactRoot, + codexExecutor: () => { + throw new Error("codex timed out"); + }, + dryMergeConflict: false, + execute: true, + forkPatchFiles: [], + upstreamChangedFiles: [], + useCodex: true, + }); + if ( + codexThrowAudit.classification !== "blocked" || + !readFileSync( + path.join(codexThrowArtifactRoot, "codex-upstream-audit.stderr.log"), + "utf8", + ).includes("codex timed out") + ) { + throw new Error("NapCat Codex thrown executor stderr self-check failed"); + } + const unsafeArtifactRootPath = path.resolve("/tmp/not-kt"); + const unsafeArtifactRootExisted = existsSync(unsafeArtifactRootPath); + let unsafeArtifactRootRejected = false; + let unsafeArtifactExecutorCalled = false; + try { + buildNapcatUpstreamAudit({ + artifactRoot: "/tmp/not-kt", + codexExecutor: () => { + unsafeArtifactExecutorCalled = true; + return { ok: true, stderr: "", stdout: "{}\n" }; + }, + dryMergeConflict: false, + execute: true, + forkPatchFiles: [], + upstreamChangedFiles: [], + useCodex: true, + }); + } catch (error) { + unsafeArtifactRootRejected = String(error).includes( + "Unsafe NapCat Codex artifactRoot", + ); + } finally { + if (!unsafeArtifactRootExisted && existsSync(unsafeArtifactRootPath)) { + rmSync(unsafeArtifactRootPath, { force: true, recursive: true }); + } + } + if (!unsafeArtifactRootRejected || unsafeArtifactExecutorCalled) { + throw new Error("NapCat Codex unsafe artifact root self-check failed"); + } const dryRunAuditCommands: string[] = []; for (const command of dryRunAudit.commands) { if (!command.readOnly) { diff --git a/src/tools/napcatAutomation.ts b/src/tools/napcatAutomation.ts index 0b884e9..892d6d9 100644 --- a/src/tools/napcatAutomation.ts +++ b/src/tools/napcatAutomation.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { upstreamAuditOutputSchema } from './napcatAutomation.types.js'; import { loadNapcatAutomationPrompt } from './napcatAutomation.prompts.js'; +import { workspaceRoot } from '../core/workspace.js'; import type { NapcatAuditClassificationInput, NapcatAuditClassificationResult, @@ -85,6 +86,9 @@ const hotZoneMatchers: Array<{ pattern: string; regex: RegExp }> = [ { pattern: 'vite*.ts', regex: /(?:^|\/)vite[^/]*\.ts$/i }, ]; +const napcatNasCodexArtifactRoot = '/vol1/docker/kt-codex/artifacts'; +const napcatLocalArtifactRoot = '.kt-workspace/test-artifacts'; + /** * Quotes a POSIX shell path argument for NAS-side Codex automation commands. * @param value - Absolute or repo-relative filesystem path that will be interpreted by a Linux shell. @@ -124,16 +128,15 @@ export function buildNapcatCodexExecCommand( return [ `CODEX_HOME=${shellQuotePath(codexHome)}`, - 'codex exec', + 'codex --ask-for-approval never exec', `--cd ${shellQuotePath(input.workspaceRoot)}`, '--profile-v2 automation', '--sandbox workspace-write', - '--ask-for-approval never', '--json', '--ephemeral', `--output-schema ${shellQuotePath(input.outputSchemaPath)}`, `--output-last-message ${shellQuotePath(outputLastMessagePath)}`, - `"$(cat ${shellQuotePath(input.contextPacketPath)})"`, + `- < ${shellQuotePath(input.contextPacketPath)}`, ].join(' '); } @@ -144,7 +147,10 @@ export function buildNapcatCodexExecCommand( * @returns A stable path string that can be used by both local self-tests and Linux-side command construction. */ function joinNapcatArtifactPath(artifactRoot: string, fileName: string): string { - return path.posix.join(artifactRoot, fileName); + if (isNapcatNasCodexArtifactRoot(artifactRoot)) { + return path.posix.join(artifactRoot, fileName); + } + return path.join(artifactRoot, fileName); } /** @@ -159,6 +165,84 @@ function resolveNapcatArtifactRoot(artifactRoot?: string): string { ); } +/** + * Checks whether a filesystem path has parent-directory traversal segments. + * @param value - Raw artifact root value from user input or a default path. + * @returns True when the path contains a literal `..` segment after separator normalization. + */ +function hasParentPathSegment(value: string): boolean { + return value.replace(/\\/g, '/').split('/').includes('..'); +} + +/** + * Checks whether an artifact root targets the stabilized NAS Codex artifact directory. + * @param value - Raw or normalized artifact root path. + * @returns True when the path is `/vol1/docker/kt-codex/artifacts` or a child of it without parent-directory traversal. + */ +function isNapcatNasCodexArtifactRoot(value: string): boolean { + const normalized = value.replace(/\\/g, '/').replace(/\/+$/, ''); + return ( + !hasParentPathSegment(normalized) && + (normalized === napcatNasCodexArtifactRoot || + normalized.startsWith(`${napcatNasCodexArtifactRoot}/`)) + ); +} + +/** + * Checks whether a target path is the same as or inside a base path. + * @param targetPath - Absolute candidate path. + * @param basePath - Absolute allowed directory. + * @returns True when `targetPath` is equal to `basePath` or resolves inside it. + */ +function isPathInsideOrEqual(targetPath: string, basePath: string): boolean { + const relative = path.relative(basePath, targetPath); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + +/** + * Checks whether a local artifact root is the KT test-artifacts directory or one of its children. + * @param artifactRoot - Local relative or absolute artifact root from automation input. + * @returns True when writes are constrained to `.kt-workspace/test-artifacts`. + */ +function isNapcatLocalTestArtifactRoot(artifactRoot: string): boolean { + if (hasParentPathSegment(artifactRoot)) return false; + + const normalized = artifactRoot.replace(/\\/g, '/').replace(/\/+$/, ''); + if (!path.isAbsolute(artifactRoot)) { + return ( + normalized === napcatLocalArtifactRoot || + normalized.startsWith(`${napcatLocalArtifactRoot}/`) + ); + } + + const resolvedTarget = path.resolve(artifactRoot); + const allowedRoots = [ + path.resolve(workspaceRoot, napcatLocalArtifactRoot), + path.resolve(process.cwd(), napcatLocalArtifactRoot), + ]; + return allowedRoots.some((allowedRoot) => + isPathInsideOrEqual(resolvedTarget, allowedRoot), + ); +} + +/** + * Rejects unsafe artifact roots before execute mode writes context, JSONL, stderr, or last-message files. + * @param artifactRoot - Resolved artifact root from NapCat upstream audit input. + * @throws When the root is not a KT test-artifacts path or the NAS Codex artifact root. + */ +function assertSafeNapcatCodexArtifactRoot(artifactRoot: string): void { + if ( + isNapcatNasCodexArtifactRoot(artifactRoot) || + isNapcatLocalTestArtifactRoot(artifactRoot) + ) { + return; + } + + throw new Error( + `Unsafe NapCat Codex artifactRoot: ${artifactRoot}. Use .kt-workspace/test-artifacts/... or /vol1/docker/kt-codex/artifacts/...`, + ); +} + /** * Resolves the Linux workspace root used by the NAS Codex automation runner. * @param workspaceRoot - Optional trusted KT workspace root override. @@ -372,7 +456,16 @@ function parseNestedCodexAuditCandidate( } const record = value as Record; - for (const key of ['content', 'message', 'output', 'text', 'lastMessage']) { + for (const key of [ + 'content', + 'data', + 'item', + 'lastMessage', + 'message', + 'output', + 'payload', + 'text', + ]) { const nestedValue = record[key]; if (typeof nestedValue === 'string') { const parsed = parseNapcatCodexAuditCandidate(nestedValue); @@ -671,6 +764,8 @@ export function buildNapcatUpstreamAudit( ]; if (input.execute === true && input.useCodex === true) { + assertSafeNapcatCodexArtifactRoot(artifactRoot); + const promptName = input.promptName ?? 'upstream-audit'; const contextPacketPath = joinNapcatArtifactPath( artifactRoot, @@ -680,6 +775,10 @@ export function buildNapcatUpstreamAudit( artifactRoot, 'codex-upstream-audit.jsonl', ); + const stderrPath = joinNapcatArtifactPath( + artifactRoot, + 'codex-upstream-audit.stderr.log', + ); const lastMessagePath = joinNapcatArtifactPath( artifactRoot, `codex-${promptName}.md`, @@ -697,6 +796,7 @@ export function buildNapcatUpstreamAudit( const codexArtifacts: NapcatUpstreamAuditResult['artifacts'] = [ { path: contextPacketPath, type: 'context' }, { path: jsonlPath, type: 'jsonl' }, + { path: stderrPath, type: 'log' }, { path: lastMessagePath, type: 'markdown' }, ]; const codexCommands: NapcatAuditCommand[] = [ @@ -730,6 +830,7 @@ export function buildNapcatUpstreamAudit( } writeFileSync(jsonlPath, codexResult.stdout, 'utf8'); + writeFileSync(stderrPath, codexResult.stderr, 'utf8'); const lastMessage = existsSync(lastMessagePath) ? readFileSync(lastMessagePath, 'utf8') : ''; diff --git a/src/tools/napcatAutomation.types.ts b/src/tools/napcatAutomation.types.ts index 01a1a9f..ee61400 100644 --- a/src/tools/napcatAutomation.types.ts +++ b/src/tools/napcatAutomation.types.ts @@ -95,7 +95,7 @@ export interface NapcatUpstreamAuditInput export interface NapcatAutomationArtifact { path: string; - type: 'context' | 'json' | 'jsonl' | 'markdown' | 'script'; + type: 'context' | 'json' | 'jsonl' | 'log' | 'markdown' | 'script'; } export interface NapcatAutomationResult {