fix: 加固NapCat Codex审计执行器
This commit is contained in:
parent
3f7ed99aec
commit
c16a867c60
104
src/selfTest.ts
104
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<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) {
|
||||
|
||||
@ -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')
|
||||
: '';
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user