fix: 加固NapCat Codex审计执行器

This commit is contained in:
sunlei 2026-06-25 01:04:57 +08:00
parent 3f7ed99aec
commit c16a867c60
3 changed files with 207 additions and 10 deletions

View File

@ -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<void> {
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<void> {
"--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<void> {
artifactRoot: codexExecutionArtifactRoot,
codexExecutor: () => ({
ok: true,
stderr: "",
stderr: "schema warning",
stdout: "{\"classification\":\"surprising\"}\n",
}),
dryMergeConflict: false,
@ -176,10 +182,100 @@ export async function runSelfTest(): Promise<void> {
!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) {

View File

@ -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<string, unknown>;
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')
: '';

View File

@ -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 {