feat: 封装NapCat Codex审计执行器

This commit is contained in:
sunlei 2026-06-25 00:47:17 +08:00
parent 99408d3cec
commit 3f7ed99aec
3 changed files with 511 additions and 10 deletions

View File

@ -1,4 +1,4 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import {
@ -41,6 +41,7 @@ import {
upstreamAuditOutputSchema,
} from "./tools/napcatAutomation.types.js";
import {
buildNapcatCodexExecCommand,
buildNapcatUpstreamAudit,
classifyNapcatUpstreamAudit,
} from "./tools/napcatAutomation.js";
@ -119,6 +120,66 @@ export async function runSelfTest(): Promise<void> {
"NapCat upstream audit dry-run artifact plan self-check failed",
);
}
const codexCommand = buildNapcatCodexExecCommand({
artifactRoot: "/vol1/docker/kt-codex/artifacts/smoke",
contextPacketPath:
"/vol1/docker/kt-codex/artifacts/smoke/context-packet.md",
outputSchemaPath:
"/vol1/docker/kt-codex/workspace/KT/mcp/ktWorkflow/prompts/napcat/schemas/upstream-audit.schema.json",
promptName: "upstream-audit",
workspaceRoot: "/vol1/docker/kt-codex/workspace/KT",
});
if (!codexCommand.includes("--ask-for-approval never")) {
throw new Error("NapCat Codex runner approval policy self-check failed");
}
for (const expected of [
"CODEX_HOME='/vol1/docker/kt-codex/home/.codex'",
"--cd '/vol1/docker/kt-codex/workspace/KT'",
"--profile-v2 automation",
"--sandbox workspace-write",
"--json",
"--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')\"",
]) {
if (!codexCommand.includes(expected)) {
throw new Error(`NapCat Codex runner command missing: ${expected}`);
}
}
if (codexCommand.includes("git push") || codexCommand.includes("kubectl")) {
throw new Error("NapCat Codex runner mutation guard self-check failed");
}
const codexExecutionArtifactRoot = resolveInsideRoot(
".kt-workspace/test-artifacts/napcat-codex-runner-self-test",
);
const codexInvalidSchemaAudit = buildNapcatUpstreamAudit({
artifactRoot: codexExecutionArtifactRoot,
codexExecutor: () => ({
ok: true,
stderr: "",
stdout: "{\"classification\":\"surprising\"}\n",
}),
dryMergeConflict: false,
execute: true,
forkPatchFiles: [],
upstreamChangedFiles: [],
useCodex: true,
});
if (
codexInvalidSchemaAudit.classification !== "blocked" ||
!codexInvalidSchemaAudit.reasonCodes.includes("CODEX_SCHEMA_INVALID")
) {
throw new Error("NapCat Codex invalid schema fallback self-check failed");
}
if (
!existsSync(
path.join(codexExecutionArtifactRoot, "codex-upstream-audit.jsonl"),
) ||
!existsSync(path.join(codexExecutionArtifactRoot, "context-packet.md"))
) {
throw new Error("NapCat Codex artifact capture self-check failed");
}
const dryRunAuditCommands: string[] = [];
for (const command of dryRunAudit.commands) {
if (!command.readOnly) {

View File

@ -1,8 +1,18 @@
import { execSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { upstreamAuditOutputSchema } from './napcatAutomation.types.js';
import { loadNapcatAutomationPrompt } from './napcatAutomation.prompts.js';
import type {
NapcatAuditClassificationInput,
NapcatAuditClassificationResult,
NapcatAuditCommand,
NapcatAutomationReasonCode,
NapcatCodexExecCommandInput,
NapcatCodexExecResult,
NapcatCodexExecutor,
NapcatUpstreamAuditInput,
NapcatUpstreamAuditResult,
} from './napcatAutomation.types.js';
@ -75,6 +85,330 @@ const hotZoneMatchers: Array<{ pattern: string; regex: RegExp }> = [
{ pattern: 'vite*.ts', regex: /(?:^|\/)vite[^/]*\.ts$/i },
];
/**
* 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.
* @returns A single-quoted shell token with embedded single quotes escaped.
*/
function shellQuotePath(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
/**
* Derives the default trusted Codex home from the NAS workspace root layout.
* @param workspaceRoot - Linux workspace root passed to `codex exec --cd`, usually `/vol1/docker/kt-codex/workspace/KT`.
* @returns The colocated Codex home path, or the stabilized NAS default when the workspace root does not match the expected layout.
*/
function deriveCodexHome(workspaceRoot: string): string {
const marker = '/workspace/';
const markerIndex = workspaceRoot.indexOf(marker);
if (markerIndex >= 0) {
return path.posix.join(workspaceRoot.slice(0, markerIndex), 'home/.codex');
}
return '/vol1/docker/kt-codex/home/.codex';
}
/**
* Builds the bounded Codex CLI command used by NapCat upstream audit automation.
* @param input - Linux paths for artifact capture, the context packet prompt, JSON schema validation, and the trusted KT workspace root; `codexHome` may override the derived NAS default.
* @returns A POSIX shell command that runs `codex exec` with workspace-write sandboxing, never approval, JSONL output, and last-message artifact capture.
*/
export function buildNapcatCodexExecCommand(
input: NapcatCodexExecCommandInput,
): string {
const outputLastMessagePath = path.posix.join(
input.artifactRoot,
`codex-${input.promptName}.md`,
);
const codexHome = input.codexHome ?? deriveCodexHome(input.workspaceRoot);
return [
`CODEX_HOME=${shellQuotePath(codexHome)}`,
'codex 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)})"`,
].join(' ');
}
/**
* Builds a POSIX-style artifact child path while preserving deterministic command text for NAS execution.
* @param artifactRoot - Artifact directory supplied by automation input or the default KT test-artifact root.
* @param fileName - File name to place directly under the artifact root.
* @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);
}
/**
* Resolves the artifact root for dry-run and execute-mode NapCat upstream audit outputs.
* @param artifactRoot - Optional caller-provided artifact directory; when omitted the standard KT test-artifact folder is used.
* @returns A non-empty artifact root path.
*/
function resolveNapcatArtifactRoot(artifactRoot?: string): string {
return (
artifactRoot?.trim() ||
'.kt-workspace/test-artifacts/napcat-upstream-audit'
);
}
/**
* Resolves the Linux workspace root used by the NAS Codex automation runner.
* @param workspaceRoot - Optional trusted KT workspace root override.
* @returns The configured workspace root or the stabilized NAS kt-codex workspace path.
*/
function resolveNapcatCodexWorkspaceRoot(workspaceRoot?: string): string {
return workspaceRoot?.trim() || '/vol1/docker/kt-codex/workspace/KT';
}
/**
* Resolves the JSON schema path passed to `codex exec --output-schema`.
* @param input - Upstream audit input containing an optional schema path and workspace root.
* @returns The caller-provided schema path or the source-controlled upstream-audit schema under the trusted workspace root.
*/
function resolveNapcatCodexOutputSchemaPath(
input: NapcatUpstreamAuditInput,
): string {
if (input.outputSchemaPath?.trim()) {
return input.outputSchemaPath.trim();
}
return path.posix.join(
resolveNapcatCodexWorkspaceRoot(input.workspaceRoot),
'mcp/ktWorkflow/prompts/napcat/schemas/upstream-audit.schema.json',
);
}
/**
* Builds a deterministic Markdown context packet for Codex upstream audit reasoning.
* @param input - NapCat upstream audit metadata and optional repo/ref hints; values are serialized as context only and must not be executed.
* @param classification - Deterministic local classifier output that Codex can compare against upstream risk evidence.
* @returns Markdown prompt content with the source-controlled prompt, sanitized metadata, and explicit no-mutation constraints.
*/
function buildNapcatCodexContextPacket(
input: NapcatUpstreamAuditInput,
classification: NapcatAuditClassificationResult,
): string {
const promptText = loadNapcatAutomationPrompt('upstream-audit').trim();
const context = {
classification,
constraints: {
forbiddenCommands: ['git push', 'kubectl', 'docker build', 'deploy'],
outputSchema: 'upstream-audit.schema.json',
},
input: {
createCandidateBranch: input.createCandidateBranch === true,
dryMergeConflict: input.dryMergeConflict,
forkBranch: input.forkBranch,
forkPatchFiles: input.forkPatchFiles,
forkRepo: redactUrlCredentials(input.forkRepo),
lastAcceptedUpstreamBase: input.lastAcceptedUpstreamBase,
upstreamChangedFiles: input.upstreamChangedFiles,
upstreamReleaseTag: input.upstreamReleaseTag,
upstreamRepo: redactUrlCredentials(input.upstreamRepo),
},
schemaVersion: 1,
};
return [
'# NapCat Upstream Audit Codex Context',
'',
'## Source Prompt',
'',
promptText,
'',
'## Context',
'',
'```json',
JSON.stringify(context, null, 2),
'```',
'',
'## Safety',
'',
'- Do not edit files.',
'- Do not run git push, kubectl, docker build, deploy, or release mutation commands.',
'- Return only JSON that matches the supplied output schema.',
'',
].join('\n');
}
/**
* Redacts credentials from URL-shaped repository hints before they enter context artifacts.
* @param value - Optional repo URL or short repository identifier from automation input.
* @returns The original value when no URL credentials are present; otherwise the same URL with userinfo removed.
*/
function redactUrlCredentials(value?: string): string | undefined {
if (!value) return undefined;
return value.replace(/:\/\/[^/@\s]+@/g, '://');
}
/**
* Runs the Codex CLI command with the task-required ten minute timeout.
* @param command - POSIX shell command produced by `buildNapcatCodexExecCommand`.
* @returns Captured stdout/stderr and an ok flag; failures are returned so callers can preserve artifacts and classify as blocked.
*/
function executeNapcatCodexCommand(command: string): NapcatCodexExecResult {
try {
const stdout = execSync(command, {
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
shell: process.platform === 'win32' ? undefined : '/bin/bash',
timeout: 10 * 60 * 1000,
});
return { ok: true, stderr: '', stdout };
} catch (error) {
const execError = error as {
stderr?: Buffer | string;
stdout?: Buffer | string;
};
return {
ok: false,
stderr: bufferLikeToString(execError.stderr),
stdout: bufferLikeToString(execError.stdout),
};
}
}
/**
* Converts optional exec output into UTF-8 text for artifact preservation.
* @param value - Buffer or string captured from a child-process result.
* @returns String output, or an empty string when the stream was not captured.
*/
function bufferLikeToString(value?: Buffer | string): string {
if (!value) return '';
return Buffer.isBuffer(value) ? value.toString('utf8') : value;
}
/**
* Parses Codex stdout and last-message content into the strict upstream audit schema.
* @param stdout - JSONL stdout emitted by `codex exec --json`; the final schema payload may be a raw line or nested text field.
* @param lastMessage - Optional content from `--output-last-message` when Codex wrote one.
* @returns Parsed classification result, or null when no schema-valid payload can be found.
*/
function parseNapcatCodexAuditOutput(
stdout: string,
lastMessage: string,
): NapcatAuditClassificationResult | null {
const candidates = [
...stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.reverse(),
lastMessage,
stdout,
];
for (const candidate of candidates) {
const parsed = parseNapcatCodexAuditCandidate(candidate);
if (parsed) return parsed;
}
return null;
}
/**
* Attempts to parse one raw Codex output candidate, including nested string payloads.
* @param candidate - Raw JSON, JSONL line, markdown fenced JSON, or nested Codex event text.
* @returns A schema-valid upstream audit result, or null when the candidate does not contain one.
*/
function parseNapcatCodexAuditCandidate(
candidate: string,
): NapcatAuditClassificationResult | null {
const trimmed = candidate.trim();
if (!trimmed) return null;
for (const jsonText of collectJsonTextCandidates(trimmed)) {
try {
const value = JSON.parse(jsonText) as unknown;
const direct = upstreamAuditOutputSchema.safeParse(value);
if (direct.success) return direct.data;
const nested = parseNestedCodexAuditCandidate(value);
if (nested) return nested;
} catch {
continue;
}
}
return null;
}
/**
* Extracts direct and fenced JSON snippets from Codex text output.
* @param value - Raw Codex event text or last-message Markdown.
* @returns Candidate JSON strings in most-specific-first order.
*/
function collectJsonTextCandidates(value: string): string[] {
const candidates = [value];
const fencedJson = value.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1]?.trim();
if (fencedJson) {
candidates.unshift(fencedJson);
}
return candidates;
}
/**
* Searches common Codex JSONL event shapes for a nested schema payload string.
* @param value - Parsed JSONL event or raw nested JSON value from Codex stdout.
* @returns A schema-valid upstream audit result, or null when no nested payload matches.
*/
function parseNestedCodexAuditCandidate(
value: unknown,
): NapcatAuditClassificationResult | null {
if (!value || typeof value !== 'object') return null;
if (Array.isArray(value)) {
for (const item of [...value].reverse()) {
const parsed = parseNestedCodexAuditCandidate(item);
if (parsed) return parsed;
}
return null;
}
const record = value as Record<string, unknown>;
for (const key of ['content', 'message', 'output', 'text', 'lastMessage']) {
const nestedValue = record[key];
if (typeof nestedValue === 'string') {
const parsed = parseNapcatCodexAuditCandidate(nestedValue);
if (parsed) return parsed;
} else if (nestedValue && typeof nestedValue === 'object') {
const parsed = parseNestedCodexAuditCandidate(nestedValue);
if (parsed) return parsed;
}
}
return null;
}
/**
* Builds the blocked result used when Codex execution or schema parsing fails.
* @param artifacts - Artifact paths already written or reserved for the failed execution.
* @param commands - Command metadata returned to callers for auditability.
* @returns A blocked upstream audit result with the `CODEX_SCHEMA_INVALID` reason code.
*/
function buildCodexSchemaInvalidResult(
artifacts: NapcatUpstreamAuditResult['artifacts'],
commands: NapcatAuditCommand[],
): NapcatUpstreamAuditResult {
return {
artifacts,
classification: 'blocked',
commands,
execute: true,
hotZonePatterns: [...napcatUpstreamHotZonePatterns],
reasonCodes: ['CODEX_SCHEMA_INVALID'],
recommendedAction: 'block',
summary:
'Codex upstream audit execution did not produce schema-valid JSON; inspect context, JSONL stdout, and last-message artifacts before continuing.',
};
}
/**
* Checks whether Git audit metadata contains a repo-relative path that the classifier can trust.
* @param value - Raw path string from fork/upstream Git diff collectors; empty entries are allowed because collectors may emit blank lines.
@ -289,14 +623,15 @@ export function classifyNapcatUpstreamAudit(
}
/**
* Builds a read-only upstream audit plan for NapCatQQ release sync automation.
* @param input - Same deterministic classifier input supplied by the dry-run collector; values are embedded only in the returned classification, never executed.
* @returns Dry-run commands and artifact paths for a future collector, with no Git merge/push, Docker, Kubernetes, or release mutations.
* Builds or executes a NapCatQQ upstream audit plan.
* @param input - Deterministic collector metadata plus optional Codex automation settings; execute mode writes context/stdout artifacts and runs the bounded Codex command only when both `execute` and `useCodex` are true.
* @returns Dry-run classification and commands, or Codex schema output with artifact paths; failures are blocked with `CODEX_SCHEMA_INVALID` and preserved artifacts.
*/
export function buildNapcatUpstreamAudit(
input: NapcatAuditClassificationInput,
input: NapcatUpstreamAuditInput,
): NapcatUpstreamAuditResult {
const classification = classifyNapcatUpstreamAudit(input);
const artifactRoot = resolveNapcatArtifactRoot(input.artifactRoot);
const commands: NapcatAuditCommand[] = [
{
command: 'git status --short --branch',
@ -335,19 +670,99 @@ export function buildNapcatUpstreamAudit(
},
];
if (input.execute === true && input.useCodex === true) {
const promptName = input.promptName ?? 'upstream-audit';
const contextPacketPath = joinNapcatArtifactPath(
artifactRoot,
'context-packet.md',
);
const jsonlPath = joinNapcatArtifactPath(
artifactRoot,
'codex-upstream-audit.jsonl',
);
const lastMessagePath = joinNapcatArtifactPath(
artifactRoot,
`codex-${promptName}.md`,
);
const outputSchemaPath = resolveNapcatCodexOutputSchemaPath(input);
const workspaceRoot = resolveNapcatCodexWorkspaceRoot(input.workspaceRoot);
const codexCommand = buildNapcatCodexExecCommand({
artifactRoot,
codexHome: input.codexHome,
contextPacketPath,
outputSchemaPath,
promptName,
workspaceRoot,
});
const codexArtifacts: NapcatUpstreamAuditResult['artifacts'] = [
{ path: contextPacketPath, type: 'context' },
{ path: jsonlPath, type: 'jsonl' },
{ path: lastMessagePath, type: 'markdown' },
];
const codexCommands: NapcatAuditCommand[] = [
{
command: codexCommand,
description:
'Run Codex CLI upstream audit with bounded sandboxing and capture JSONL/last-message artifacts.',
readOnly: false,
},
];
mkdirSync(artifactRoot, { recursive: true });
writeFileSync(
contextPacketPath,
buildNapcatCodexContextPacket(input, classification),
'utf8',
);
writeFileSync(lastMessagePath, '', 'utf8');
let codexResult: NapcatCodexExecResult;
try {
const executor: NapcatCodexExecutor =
input.codexExecutor ?? executeNapcatCodexCommand;
codexResult = executor(codexCommand);
} catch (error) {
codexResult = {
ok: false,
stderr: String(error),
stdout: '',
};
}
writeFileSync(jsonlPath, codexResult.stdout, 'utf8');
const lastMessage = existsSync(lastMessagePath)
? readFileSync(lastMessagePath, 'utf8')
: '';
const parsed = codexResult.ok
? parseNapcatCodexAuditOutput(codexResult.stdout, lastMessage)
: null;
if (!parsed) {
return buildCodexSchemaInvalidResult(codexArtifacts, codexCommands);
}
return {
...parsed,
artifacts: codexArtifacts,
commands: codexCommands,
execute: true,
hotZonePatterns: [...napcatUpstreamHotZonePatterns],
};
}
return {
...classification,
artifacts: [
{
path: '.kt-workspace/test-artifacts/napcat-upstream-audit/context.md',
path: joinNapcatArtifactPath(artifactRoot, 'context.md'),
type: 'markdown',
},
{
path: '.kt-workspace/test-artifacts/napcat-upstream-audit/classification.json',
path: joinNapcatArtifactPath(artifactRoot, 'classification.json'),
type: 'json',
},
{
path: '.kt-workspace/test-artifacts/napcat-upstream-audit/files.json',
path: joinNapcatArtifactPath(artifactRoot, 'files.json'),
type: 'json',
},
],

View File

@ -56,16 +56,41 @@ export interface NapcatAuditCommand {
readOnly: boolean;
}
export interface NapcatUpstreamAuditInput {
export interface NapcatCodexExecCommandInput {
artifactRoot: string;
codexHome?: string;
contextPacketPath: string;
outputSchemaPath: string;
promptName: 'upstream-audit';
workspaceRoot: string;
}
export interface NapcatCodexExecResult {
ok: boolean;
stderr: string;
stdout: string;
}
export type NapcatCodexExecutor = (
command: string,
) => NapcatCodexExecResult;
export interface NapcatUpstreamAuditInput
extends NapcatAuditClassificationInput {
artifactRoot?: string;
codexExecutor?: NapcatCodexExecutor;
codexHome?: string;
createCandidateBranch?: boolean;
execute?: boolean;
forkBranch?: string;
forkRepo?: string;
lastAcceptedUpstreamBase?: string;
outputSchemaPath?: string;
promptName?: 'upstream-audit';
upstreamReleaseTag?: string;
upstreamRepo?: string;
useCodex?: boolean;
workspaceRoot?: string;
}
export interface NapcatAutomationArtifact {
@ -85,7 +110,7 @@ export interface NapcatUpstreamAuditResult
extends NapcatAuditClassificationResult {
artifacts: NapcatAutomationArtifact[];
commands: NapcatAuditCommand[];
execute: false;
execute: boolean;
hotZonePatterns: string[];
}