feat: add code queue prompt live auth lint

This commit is contained in:
Codex
2026-05-23 02:49:40 +00:00
committed by unidesk-code-queue-runner
parent 67fee32beb
commit e96a1acc9a
7 changed files with 488 additions and 4 deletions
@@ -0,0 +1,186 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { codexPromptLiveAuthorizationLintForTest } from "./src/code-queue";
type JsonRecord = Record<string, unknown>;
function assertCondition(condition: unknown, message: string, detail: unknown = {}): void {
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
}
function asRecord(value: unknown): JsonRecord {
assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), "expected JSON object", { value });
return value as JsonRecord;
}
function nestedRecord(value: unknown, path: string[]): JsonRecord {
let current: unknown = value;
for (const key of path) {
current = asRecord(current)[key];
}
return asRecord(current);
}
function stringArray(value: unknown): string[] {
return Array.isArray(value) ? value.map((item) => String(item)) : [];
}
function runCli(args: string[], stdin?: string): { status: number | null; stdout: string; stderr: string; json: JsonRecord | null } {
const result = spawnSync("bun", ["scripts/cli.ts", ...args], {
cwd: process.cwd(),
input: stdin,
encoding: "utf8",
});
const stdout = String(result.stdout || "");
let json: JsonRecord | null = null;
try {
json = JSON.parse(stdout) as JsonRecord;
} catch {
json = null;
}
return {
status: result.status,
stdout,
stderr: String(result.stderr || ""),
json,
};
}
const readOnlyPrompt = `
DEV test class: read-only
允许的 live mutation: none
禁止动作: prod mutation, secret values, database patch, Code Queue backend restart, interrupt/cancel.
closeout fields: report class, live mutation false, commands, target environment, evidence, residual risk.
Task: update docs and run contract tests only.
Validation: git diff --check and bun scripts/code-queue-prompt-lint-contract-test.ts.
`;
const unclassifiedM3SmokePrompt = `
Run the HWLAB M3 smoke on DEV and verify res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1.
Do not touch production.
`;
const liveReadPrompt = `
DEV test class: live-read
允许的 live mutation: none
禁止动作: no POST/PUT/PATCH/DELETE, no deploy, no task trigger, no prod mutation.
closeout fields: report class, live mutation false, endpoints read, namespace/service, evidence, residual risk.
Read live DEV health and status only: GET /health and kubectl get pods.
`;
const liveMutatingPrompt = `
DEV test class: live-mutating
允许的 live mutation: run exactly one DEV HWLAB M3 operation for res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1, record operation/audit/evidence ids, then observe recovery.
禁止动作: no prod mutation, no secret values, no database manual patch, no Code Queue backend restart, no unrelated interrupt/cancel.
closeout fields: report class, live mutation true, command summary, DEV target, operation/audit/evidence ids, rollback observation, residual risk.
`;
const secretBearingPrompt = `
DEV test class: live-mutating
允许的 live mutation: POST /api/run?token=ds-secret-must-not-print once in DEV only.
禁止动作: no prod mutation, no database patch.
closeout fields: report class, live mutation true, command summary and evidence id.
`;
function assertLintShape(lint: JsonRecord): void {
assertCondition(lint.dryRun === true, "lint must be dry-run", lint);
assertCondition(lint.mutation === false, "lint must be non-mutating", lint);
assertCondition(asRecord(lint.policy).printsPromptText === false, "lint policy must not print full prompt", lint);
assertCondition(asRecord(lint.promptShape).textEchoed === false, "lint shape must not echo prompt text", lint);
assertCondition(Array.isArray(lint.signals), "lint must expose signals", lint);
const json = JSON.stringify(lint);
assertCondition(!json.includes("ds-secret-must-not-print"), "lint must not print secret marker", lint);
}
export function runCodeQueuePromptLintContract(): JsonRecord {
const readOnly = asRecord(codexPromptLiveAuthorizationLintForTest(readOnlyPrompt));
assertLintShape(readOnly);
assertCondition(readOnly.ok === true, "well-formed read-only prompt should pass", readOnly);
assertCondition(readOnly.declaredClass === "read-only", "read-only prompt should declare read-only", readOnly);
assertCondition(readOnly.effectiveClass === "read-only", "read-only effective class mismatch", readOnly);
assertCondition(readOnly.requiredClass === "read-only", "read-only required class mismatch", readOnly);
assertCondition(readOnly.dispatchDisposition === "ready", "read-only prompt should be dispatch-ready", readOnly);
const liveRead = asRecord(codexPromptLiveAuthorizationLintForTest(liveReadPrompt));
assertLintShape(liveRead);
assertCondition(liveRead.ok === true, "well-formed live-read prompt should pass", liveRead);
assertCondition(liveRead.declaredClass === "live-read", "live-read prompt should declare live-read", liveRead);
assertCondition(liveRead.requiredClass === "live-read", "live-read required class mismatch", liveRead);
assertCondition(liveRead.dispatchDisposition === "ready", "live-read prompt should be dispatch-ready", liveRead);
const unclassifiedM3 = asRecord(codexPromptLiveAuthorizationLintForTest(unclassifiedM3SmokePrompt));
assertLintShape(unclassifiedM3);
assertCondition(unclassifiedM3.ok === false, "unclassified M3 smoke should fail lint", unclassifiedM3);
assertCondition(unclassifiedM3.declaredClass === null, "unclassified prompt should have no declared class", unclassifiedM3);
assertCondition(unclassifiedM3.effectiveClass === "read-only", "unclassified prompt should default to read-only", unclassifiedM3);
assertCondition(unclassifiedM3.requiredClass === "live-mutating", "M3 smoke should require live-mutating", unclassifiedM3);
assertCondition(unclassifiedM3.dispatchDisposition === "needs-authorization", "unclassified live mutation should need authorization", unclassifiedM3);
assertCondition(stringArray(unclassifiedM3.missingOrContradictory).some((item) => item.includes("missing DEV test class")), "unclassified prompt should report missing class", unclassifiedM3);
const liveMutating = asRecord(codexPromptLiveAuthorizationLintForTest(liveMutatingPrompt));
assertLintShape(liveMutating);
assertCondition(liveMutating.ok === true, "well-formed live-mutating prompt should pass", liveMutating);
assertCondition(liveMutating.declaredClass === "live-mutating", "live-mutating prompt should declare live-mutating", liveMutating);
assertCondition(liveMutating.requiredClass === "live-mutating", "live-mutating required class mismatch", liveMutating);
assertCondition(liveMutating.liveMutationAuthorized === true, "live-mutating prompt should be authorized when allowed mutation is enumerated", liveMutating);
const secretBearing = asRecord(codexPromptLiveAuthorizationLintForTest(secretBearingPrompt));
assertLintShape(secretBearing);
assertCondition(secretBearing.requiredClass === "live-mutating", "secret-bearing live mutation should still classify", secretBearing);
assertCondition(!JSON.stringify(secretBearing).includes("ds-secret-must-not-print"), "prompt lint evidence must redact secret-looking values", secretBearing);
const tmp = mkdtempSync(join(tmpdir(), "unidesk-code-queue-prompt-lint-"));
const promptFile = join(tmp, "prompt.md");
writeFileSync(promptFile, liveMutatingPrompt, "utf8");
try {
const cliLint = runCli(["codex", "prompt-lint", "--prompt-file", promptFile]);
assertCondition(cliLint.status === 0 && cliLint.json?.ok === true, "prompt-lint CLI should succeed for authorized live-mutating prompt", cliLint.json ?? { stdout: cliLint.stdout });
const lintData = nestedRecord(cliLint.json?.data, []);
assertCondition(lintData.dryRun === true && lintData.mutation === false, "prompt-lint CLI should be dry-run and non-mutating", lintData);
assertCondition(lintData.declaredClass === "live-mutating", "prompt-lint CLI should classify live-mutating", lintData);
assertCondition(!JSON.stringify(lintData).includes("run exactly one DEV HWLAB M3 operation"), "prompt-lint CLI should not echo full prompt text", lintData);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
const submitDryRun = runCli(["codex", "submit", "--prompt-stdin", "--dry-run"], unclassifiedM3SmokePrompt);
assertCondition(submitDryRun.status === 0 && submitDryRun.json?.ok === true, "submit dry-run should still succeed for commander review", submitDryRun.json ?? { stdout: submitDryRun.stdout });
const submitPromptLint = nestedRecord(submitDryRun.json?.data, ["promptLint"]);
assertCondition(submitPromptLint.dispatchDisposition === "needs-authorization", "submit dry-run should embed prompt lint authorization blocker", submitPromptLint);
assertCondition(submitPromptLint.requiredClass === "live-mutating", "submit dry-run lint should require live-mutating", submitPromptLint);
const steerDryRun = runCli(["codex", "steer", "codex_test_task", "--prompt-stdin", "--dry-run"], unclassifiedM3SmokePrompt);
assertCondition(steerDryRun.status === 0 && steerDryRun.json?.ok === true, "steer dry-run should succeed for commander review", steerDryRun.json ?? { stdout: steerDryRun.stdout });
const steerPromptLint = nestedRecord(steerDryRun.json?.data, ["promptLint"]);
assertCondition(steerPromptLint.dispatchDisposition === "needs-authorization", "steer dry-run should embed prompt lint authorization blocker", steerPromptLint);
const help = runCli(["codex", "help"]);
assertCondition(help.status === 0 && help.json?.ok === true, "codex help should succeed", help.json ?? { stdout: help.stdout });
const helpData = nestedRecord(help.json?.data, []);
const usage = stringArray(helpData.usage);
assertCondition(usage.some((line) => line.includes("codex prompt-lint")), "codex help should list prompt-lint", helpData);
const authorizationHelp = nestedRecord(helpData, ["promptLiveAuthorization"]);
assertCondition(stringArray(authorizationHelp.classes).includes("live-mutating"), "help should document live-mutating class", authorizationHelp);
assertCondition(authorizationHelp.defaultWhenMissing === "read-only", "help should document read-only default", authorizationHelp);
return {
ok: true,
checks: [
"prompt-lint classifies read-only/live-read/live-mutating prompts",
"unclassified HWLAB M3 smoke defaults read-only but requires live-mutating authorization",
"prompt-lint evidence redacts secret-looking values",
"prompt-lint CLI is dry-run, non-mutating, and does not echo full prompt text",
"submit --dry-run embeds prompt live-authorization lint",
"steer --dry-run embeds prompt live-authorization lint",
"codex help documents prompt-lint and authorization classes",
],
};
}
if (import.meta.main) {
process.stdout.write(`${JSON.stringify(runCodeQueuePromptLintContract(), null, 2)}\n`);
}
+4
View File
@@ -31,6 +31,7 @@ const syntaxFiles = [
"scripts/host-codex-commander-skeleton-contract-test.ts",
"scripts/auth-broker-contract-test.ts",
"scripts/code-queue-cli-disclosure-contract-test.ts",
"scripts/code-queue-prompt-lint-contract-test.ts",
"scripts/code-queue-cli-steer-test.ts",
"scripts/code-queue-cli-submit-prompt-contract-test.ts",
"scripts/code-queue-cli-read-terminal-contract-test.ts",
@@ -311,6 +312,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
fileItem("scripts/code-queue-pr-preflight-contract-test.ts"),
fileItem("scripts/code-queue-runner-skills-contract-test.ts"),
fileItem("scripts/code-queue-cli-disclosure-contract-test.ts"),
fileItem("scripts/code-queue-prompt-lint-contract-test.ts"),
fileItem("scripts/code-queue-cli-steer-test.ts"),
fileItem("scripts/code-queue-cli-read-terminal-contract-test.ts"),
fileItem("scripts/code-queue-submit-routing-contract-test.ts"),
@@ -353,6 +355,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
items.push(commandItem("code-queue:pr-preflight-contract", ["bun", "scripts/code-queue-pr-preflight-contract-test.ts"], 30_000));
items.push(commandItem("code-queue:runner-skills-contract", ["bun", "scripts/code-queue-runner-skills-contract-test.ts"], 30_000));
items.push(commandItem("code-queue:cli-disclosure-contract", ["bun", "scripts/code-queue-cli-disclosure-contract-test.ts"], 30_000));
items.push(commandItem("code-queue:prompt-lint-contract", ["bun", "scripts/code-queue-prompt-lint-contract-test.ts"], 30_000));
items.push(commandItem("code-queue:cli-steer-contract", ["bun", "scripts/code-queue-cli-steer-test.ts"], 30_000));
items.push(commandItem("code-queue:read-terminal-contract", ["bun", "scripts/code-queue-cli-read-terminal-contract-test.ts"], 30_000));
items.push(commandItem("code-queue:submit-prompt-contract", ["bun", "scripts/code-queue-cli-submit-prompt-contract-test.ts"], 30_000));
@@ -385,6 +388,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
items.push(skippedItem("code-queue:pr-preflight-contract", "Code Queue PR preflight contract is opt-in with script checks", "--scripts-typecheck or --full"));
items.push(skippedItem("code-queue:runner-skills-contract", "Code Queue runner skill availability contract is opt-in with script checks", "--scripts-typecheck or --full"));
items.push(skippedItem("code-queue:cli-disclosure-contract", "Code Queue CLI disclosure/noise contract is opt-in with script checks", "--scripts-typecheck or --full"));
items.push(skippedItem("code-queue:prompt-lint-contract", "Code Queue prompt live-authorization lint contract is opt-in with script checks", "--scripts-typecheck or --full"));
items.push(skippedItem("code-queue:cli-steer-contract", "Code Queue steer CLI contract is opt-in with script checks", "--scripts-typecheck or --full"));
items.push(skippedItem("code-queue:read-terminal-contract", "Code Queue terminal read contract is opt-in with script checks", "--scripts-typecheck or --full"));
items.push(skippedItem("code-queue:submit-prompt-contract", "Code Queue submit prompt contract is opt-in with script checks", "--scripts-typecheck or --full"));
+282 -1
View File
@@ -67,6 +67,71 @@ interface CodexJudgeOptions {
includePrompt: boolean;
}
type LiveTestAuthorizationClass = "read-only" | "live-read" | "live-mutating";
type PromptLintSeverity = "info" | "warning" | "block";
type PromptLintDisposition = "ready" | "review" | "needs-authorization";
interface CodexPromptLintOptions {
prompt: string;
}
interface PromptLintSignal {
id: string;
severity: PromptLintSeverity;
matched: boolean;
evidence: string[];
message: string;
}
interface PromptLiveAuthorizationLint {
ok: boolean;
dryRun: true;
mutation: false;
dispatchDisposition: PromptLintDisposition;
declaredClass: LiveTestAuthorizationClass | null;
effectiveClass: LiveTestAuthorizationClass;
requiredClass: LiveTestAuthorizationClass;
defaultedReadOnly: boolean;
liveMutationAuthorized: boolean;
promptShape: {
chars: number;
lines: number;
textEchoed: false;
};
requiredPromptFields: {
devTestClass: {
present: boolean;
value: LiveTestAuthorizationClass | null;
allowedValues: LiveTestAuthorizationClass[];
};
allowedLiveMutation: {
present: boolean;
nonNone: boolean;
requiredWhen: "live-mutating";
};
forbiddenActions: {
present: boolean;
};
closeoutFields: {
present: boolean;
};
};
signals: PromptLintSignal[];
missingOrContradictory: string[];
policy: {
defaultWhenUnclassified: "read-only";
promptLintOnly: true;
accessesLiveService: false;
printsPromptText: false;
reference: string;
};
commands: {
lintFile: string;
submitDryRun: string;
steerDryRun: string;
};
}
interface CodexSubmitOptions {
prompt: string;
queueId: string | undefined;
@@ -892,6 +957,196 @@ function routeSignal(id: string, severity: SubmitRouteSignalSeverity, evidence:
return { id, severity, matched: evidence.length > 0, evidence, message };
}
function promptLintSignal(id: string, severity: PromptLintSeverity, evidence: string[], message: string): PromptLintSignal {
return { id, severity, matched: evidence.length > 0, evidence, message };
}
function declaredLiveTestClass(prompt: string): LiveTestAuthorizationClass | null {
const patterns: Array<[LiveTestAuthorizationClass, RegExp[]]> = [
["live-mutating", [
/\bDEV\s+test\s+class\s*[:]\s*`?live-mutating`?/iu,
/\blive\s+test\s+class\s*[:]\s*`?live-mutating`?/iu,
/\btest\s+class\s*[:]\s*`?live-mutating`?/iu,
/\bDEV\s+(?:)?\s*[:]\s*`?live-mutating`?/iu,
]],
["live-read", [
/\bDEV\s+test\s+class\s*[:]\s*`?live-read`?/iu,
/\blive\s+test\s+class\s*[:]\s*`?live-read`?/iu,
/\btest\s+class\s*[:]\s*`?live-read`?/iu,
/\bDEV\s+(?:)?\s*[:]\s*`?live-read`?/iu,
]],
["read-only", [
/\bDEV\s+test\s+class\s*[:]\s*`?read-only`?/iu,
/\blive\s+test\s+class\s*[:]\s*`?read-only`?/iu,
/\btest\s+class\s*[:]\s*`?read-only`?/iu,
/\bDEV\s+(?:)?\s*[:]\s*`?read-only`?/iu,
]],
];
for (const [value, valuePatterns] of patterns) {
if (valuePatterns.some((pattern) => pattern.test(prompt))) return value;
}
return null;
}
function liveClassRank(value: LiveTestAuthorizationClass): number {
if (value === "read-only") return 0;
if (value === "live-read") return 1;
return 2;
}
function hasPromptField(prompt: string, patterns: RegExp[]): boolean {
return patterns.some((pattern) => pattern.test(prompt));
}
function sanitizePromptLintEvidence(evidence: string[]): string[] {
return evidence.map((item) => item
.replace(/([?&](?:token|api[_-]?key|secret|password|credential)=)[^&\s]+/giu, "$1<redacted>")
.replace(/((?:token|api[_-]?key|secret|password|credential)\s*[:=]\s*)[^\s,;]+/giu, "$1<redacted>")
.replace(/(Bearer\s+)[A-Za-z0-9._~+/-]+=*/giu, "$1<redacted>")
.slice(0, 160));
}
function buildPromptLiveAuthorizationLint(prompt: string): PromptLiveAuthorizationLint {
const declaredClass = declaredLiveTestClass(prompt);
const effectiveClass = declaredClass ?? "read-only";
const allowedClasses: LiveTestAuthorizationClass[] = ["read-only", "live-read", "live-mutating"];
const liveReadEvidence = sanitizePromptLintEvidence(regexEvidenceWithoutNegatedContext(prompt, [
/\blive[- ]read\b/giu,
/\blive\s+(?:dev\s+)?(?:service|runtime|endpoint|health|status|logs?|metrics?)\b/giu,
/\bGET\s+\/(?:health|status|live|metrics|api\/diagnostics)\b/gu,
/\bkubectl\s+(?:get|describe|logs)\b/giu,
/\bmicroservice\s+(?:health|status|diagnostics)\b/giu,
/\bdiagnostics\b|\bstatus\b|\bmetrics\b|\blogs?\b/giu,
/(?:||||)/gu,
/\s*(?:DEV|live||||)/giu,
]));
const liveMutationEvidence = sanitizePromptLintEvidence(regexEvidenceWithoutNegatedContext(prompt, [
/\blive-mutating\b/giu,
/\bDEV\s+smoke\b|\blive\s+smoke\b|\bM3\s+smoke\b/giu,
/\bdeploy\s+apply\b|\brollout\s+restart\b|\bkubectl\s+(?:apply|delete|patch|rollout)\b/giu,
/\b(?:POST|PUT|PATCH|DELETE)\s+\/[A-Za-z0-9_./:-]*/gu,
/\bcodex\s+(?:submit|steer|interrupt|cancel)\b/giu,
/\btask\s+(?:submit|steer|retry|trigger)\b/giu,
/\btrigger\s+(?:schedule|job|task|operation|audit|evidence)\b/giu,
/\bschedule\s+(?:run|retry-run|delete)\b/giu,
/\b(?:create|write|post|put|patch)\b[^\n]{0,40}\b(?:operation|audit|evidence)\b/giu,
/\b(?:operation|audit|evidence)\s+(?:id|record|write|create)\b/giu,
/\bDO\d+\b|\bDI\d+\b|\bres_boxsimu_\d+\b|\bhwlab-patch-panel\b/giu,
/||||||(?:|operation|audit|evidence)||/gu,
]));
const prodMutationEvidence = sanitizePromptLintEvidence(regexEvidenceWithoutNegatedContext(prompt, [
/\bprod(?:uction)?\b[^\n]*(?:deploy|restart|write|mutation|mutating|apply|rollout|delete|patch)\b/giu,
/\b(?:deploy|restart|write|mutation|mutating|apply|rollout|delete|patch)\b[^\n]*\bprod(?:uction)?\b/giu,
/[^\n]*(?:|||||)/gu,
]));
const requiredClass = liveMutationEvidence.length > 0 || prodMutationEvidence.length > 0
? "live-mutating"
: liveReadEvidence.length > 0
? "live-read"
: "read-only";
const allowedLiveMutationPresent = hasPromptField(prompt, [
/\ballowed\s+live\s+mutation\s*[:]/iu,
/\s*live\s*mutation\s*[:]/iu,
/(?:||)?(?:||mutation)\s*[:]/iu,
]);
const allowedLiveMutationNone = hasPromptField(prompt, [
/\ballowed\s+live\s+mutation\s*[:]\s*(?:`?none`?||)(?:\s|$|[.;,])/iu,
/\s*live\s*mutation\s*[:]\s*(?:`?none`?||)(?:\s|$|[.;,])/iu,
/(?:||)?(?:||mutation)\s*[:]\s*(?:`?none`?||)(?:\s|$|[.;,])/iu,
]);
const forbiddenActionsPresent = hasPromptField(prompt, [
/\bforbidden\s+actions?\s*[:]/iu,
/\s*[:]/u,
/\s*[:]/u,
]);
const closeoutFieldsPresent = hasPromptField(prompt, [
/\bcloseout\s+fields?\s*[:]/iu,
/\bfinal\s+response\b[^\n]*(?:must|include|report)/iu,
/\b\s*[:]/u,
/\bfinal\s+response\b[^\n]*/iu,
]);
const effectiveInsufficient = liveClassRank(effectiveClass) < liveClassRank(requiredClass);
const liveMutationAuthorized = effectiveClass === "live-mutating" && allowedLiveMutationPresent && !allowedLiveMutationNone;
const contradictionEvidence = [
...(effectiveClass === "read-only" && liveMutationEvidence.length > 0 ? ["declares/read-only but prompt contains live mutation signals"] : []),
...(effectiveClass === "live-read" && liveMutationEvidence.length > 0 ? ["declares/live-read but prompt contains live mutation signals"] : []),
...(effectiveClass === "live-mutating" && allowedLiveMutationNone ? ["declares/live-mutating but allowed live mutation is none"] : []),
...(prodMutationEvidence.length > 0 ? prodMutationEvidence.map((item) => `prod mutation signal: ${item}`) : []),
];
const missingOrContradictory = [
...(declaredClass === null ? ["missing DEV test class; defaulting to read-only"] : []),
...(effectiveInsufficient ? [`effective class ${effectiveClass} is below required ${requiredClass}`] : []),
...(requiredClass === "live-mutating" && !allowedLiveMutationPresent ? ["live-mutating prompt must include allowed live mutation"] : []),
...(requiredClass === "live-mutating" && allowedLiveMutationNone ? ["live-mutating prompt cannot set allowed live mutation to none"] : []),
...(!forbiddenActionsPresent ? ["missing forbidden actions"] : []),
...(!closeoutFieldsPresent ? ["missing closeout fields"] : []),
...contradictionEvidence,
];
const signals = [
promptLintSignal("declared-dev-test-class", "info", declaredClass === null ? [] : [declaredClass], "Prompt explicitly declares DEV test class."),
promptLintSignal("live-read-signal", "warning", liveReadEvidence, "Prompt appears to read live DEV service state, logs, health, status, metrics, or Kubernetes objects."),
promptLintSignal("live-mutation-signal", "block", liveMutationEvidence, "Prompt appears to trigger runtime writes, deployment, task control, operation/audit/evidence creation, or HWLAB DO/DI activity."),
promptLintSignal("prod-mutation-signal", "block", prodMutationEvidence, "Prompt appears to mention production mutation; Code Queue runner prompts must not implicitly authorize this."),
promptLintSignal("allowed-live-mutation-field", requiredClass === "live-mutating" ? "block" : "info", allowedLiveMutationPresent && !allowedLiveMutationNone ? ["present"] : [], "live-mutating prompts must enumerate allowed live mutation commands and target state changes."),
promptLintSignal("forbidden-actions-field", "warning", forbiddenActionsPresent ? ["present"] : [], "Prompt should list forbidden high-risk actions."),
promptLintSignal("closeout-fields-field", "warning", closeoutFieldsPresent ? ["present"] : [], "Prompt should require final closeout fields for class, mutation, commands, targets, evidence, and residual risk."),
];
const ok = missingOrContradictory.length === 0;
const dispatchDisposition: PromptLintDisposition = ok
? "ready"
: requiredClass === "live-mutating" || effectiveInsufficient || contradictionEvidence.length > 0
? "needs-authorization"
: "review";
return {
ok,
dryRun: true,
mutation: false,
dispatchDisposition,
declaredClass,
effectiveClass,
requiredClass,
defaultedReadOnly: declaredClass === null,
liveMutationAuthorized,
promptShape: {
chars: prompt.length,
lines: prompt.split(/\r\n|\r|\n/u).length,
textEchoed: false,
},
requiredPromptFields: {
devTestClass: {
present: declaredClass !== null,
value: declaredClass,
allowedValues: allowedClasses,
},
allowedLiveMutation: {
present: allowedLiveMutationPresent,
nonNone: allowedLiveMutationPresent && !allowedLiveMutationNone,
requiredWhen: "live-mutating",
},
forbiddenActions: {
present: forbiddenActionsPresent,
},
closeoutFields: {
present: closeoutFieldsPresent,
},
},
signals,
missingOrContradictory,
policy: {
defaultWhenUnclassified: "read-only",
promptLintOnly: true,
accessesLiveService: false,
printsPromptText: false,
reference: "docs/reference/code-queue-supervision.md#dev-测试授权分级",
},
commands: {
lintFile: "bun scripts/cli.ts codex prompt-lint --prompt-file <path>",
submitDryRun: "bun scripts/cli.ts codex submit --prompt-file <path> --dry-run",
steerDryRun: "bun scripts/cli.ts codex steer <taskId> --prompt-file <path> --dry-run",
},
};
}
function submitPolicyContract(): SubmitRoutingRecommendation["policyContract"] {
return {
selectionPrinciples: [
@@ -3318,6 +3573,16 @@ function parseSteerOptions(args: string[]): CodexSteerOptions {
};
}
function parsePromptLintOptions(args: string[]): CodexPromptLintOptions {
assertKnownOptions(args, {
flags: ["--prompt-stdin", "--stdin"],
valueOptions: ["--prompt-file", "--file"],
}, "codex prompt-lint");
return {
prompt: promptFromArgs(args, "codex prompt-lint", steerPromptValueOptions),
};
}
function submitPayload(options: CodexSubmitOptions): Record<string, unknown> {
return {
prompt: options.prompt,
@@ -4986,17 +5251,28 @@ export function codexSubmitRoutingRecommendationForTest(prompt: string, model?:
});
}
export function codexPromptLiveAuthorizationLintForTest(prompt: string): PromptLiveAuthorizationLint {
return buildPromptLiveAuthorizationLint(prompt);
}
export function codexSubmitModelRegistryForTest(models: string[] = sharedDefaultCodeModels): ReturnType<typeof submitModelRegistry> {
return submitModelRegistry(models);
}
function codexPromptLintTask(args: string[]): unknown {
const options = parsePromptLintOptions(args);
return buildPromptLiveAuthorizationLint(options.prompt);
}
function codexSubmitTask(args: string[]): unknown {
const options = parseSubmitOptions(args);
const payload = submitPayload(options);
const promptLint = buildPromptLiveAuthorizationLint(options.prompt);
if (options.dryRun) {
return {
ok: true,
dryRun: true,
promptLint,
routingRecommendation: submitRoutingRecommendation(options),
modelRegistry: submitModelRegistry(),
request: {
@@ -5027,6 +5303,7 @@ function codexInterruptTask(taskId: string): unknown {
function codexSteerTask(taskId: string, args: string[], fetcher: CodexResponseFetcher = coreInternalFetch): unknown {
const options = parseSteerOptions(args);
const promptLint = buildPromptLiveAuthorizationLint(options.prompt);
const targetPath = `/api/tasks/${encodeURIComponent(taskId)}/steer`;
const stableProxyPath = codeQueueProxyPath(targetPath);
const rawProxyEquivalent = codeQueueProxyEquivalentCommand(targetPath, "{\"prompt\":\"...\"}");
@@ -5053,6 +5330,7 @@ function codexSteerTask(taskId: string, args: string[], fetcher: CodexResponseFe
return {
ok: true,
dryRun: true,
promptLint,
request,
commands: {
run: `bun scripts/cli.ts codex steer ${taskId} --prompt-file <path>`,
@@ -5164,6 +5442,9 @@ function codexSteerTask(taskId: string, args: string[], fetcher: CodexResponseFe
export async function runCodeQueueCommand(config: UniDeskConfig, args: string[]): Promise<unknown> {
const [action = "task", taskIdArg] = args;
if (action === "prompt-lint" || action === "lint-prompt") {
return codexPromptLintTask(args.slice(1));
}
if (action === "submit" || action === "enqueue") {
return codexSubmitTask(args.slice(1));
}
@@ -5214,5 +5495,5 @@ export async function runCodeQueueCommand(config: UniDeskConfig, args: string[])
const taskId = requireTaskId(taskIdArg, "codex steer");
return codexSteerTask(taskId, args.slice(2));
}
throw new Error("codex command must be one of: submit, enqueue, task, summary, show, tasks, overview, output, judge, read, mark-read, dev-ready, health, skills-sync, pr-preflight, runtime-preflight, queues, queue list, queue create, queue merge, move, steer, interrupt, cancel");
throw new Error("codex command must be one of: prompt-lint, submit, enqueue, task, summary, show, tasks, overview, output, judge, read, mark-read, dev-ready, health, skills-sync, pr-preflight, runtime-preflight, queues, queue list, queue create, queue merge, move, steer, interrupt, cancel");
}
+12 -2
View File
@@ -52,7 +52,8 @@ export function rootHelp(): unknown {
{ command: "schedule list|get|runs|run|retry-run|delete", description: "Manage backend-core scheduled tasks and run history; schedule run <id> supports --wait-ms N and retry-run reuses the failed run's schedule." },
{ command: "schedule upsert-pgdata-backup [--time HH:MM] [--remote-base /SERVER_DATA/UNIDESK_PG_DATA]", description: "Create or update the daily PGDATA physical backup task that uploads monthly rotated archives to Baidu Netdisk." },
{ command: "codex deploy <commitId> [--provider-id D601] [--timeout-ms N]", description: "Disabled legacy Code Queue deploy path; use the dev-only artifact consumer instead." },
{ command: "codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue queueId] [--provider-id id] [--cwd path] [--model model] [--execution-mode mode] [--max-attempts N] [--reference-task-id id] [--dry-run]", description: "Submit a Code Queue task through backend-core -> code-queue proxy; --dry-run shows the structured request, while real success only confirms the write and task id." },
{ command: "codex prompt-lint [prompt|--prompt-file path|--prompt-stdin]", description: "Dry-run lint a runner prompt for DEV test class read-only/live-read/live-mutating authorization without echoing prompt text or touching live services." },
{ command: "codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue queueId] [--provider-id id] [--cwd path] [--model model] [--execution-mode mode] [--max-attempts N] [--reference-task-id id] [--dry-run]", description: "Submit a Code Queue task through backend-core -> code-queue proxy; --dry-run shows the structured request, routing recommendation, and prompt live-authorization lint while real success only confirms the write and task id." },
{ command: "codex skills-sync --dry-run [--full]", description: "Inspect the controlled runner skills hostPath lifecycle contract without copying files, restarting services, reading secrets, or mutating live runner paths." },
{ command: "codex pr-preflight [--remote] [--push-dry-run --push-dry-run-ref refs/heads/probe/<name>] [--pr-create-dry-run --pr-create-dry-run-head <head>] [--issue N] [--full|--raw]", description: "Read-only PR admission check with compact commander output by default; use --full or --raw to expand the full runtime preflight, tool, and observation payload." },
{ command: "codex task <taskId> [--detail] [--trace --tail|--from-start|--after-seq N|--before-seq N --limit N] [--full]", description: "Fetch the bounded review view by default; --detail is still capped, while --full/trace/output explicitly expand evidence." },
@@ -245,10 +246,11 @@ function scheduleHelp(): unknown {
function codexHelp(): unknown {
return {
command: "codex deploy|submit|task|tasks|output|read|dev-ready|skills-sync|pr-preflight|judge|steer|interrupt|cancel|queues|queue|move",
command: "codex deploy|prompt-lint|submit|task|tasks|output|read|dev-ready|skills-sync|pr-preflight|judge|steer|interrupt|cancel|queues|queue|move",
output: "json",
usage: [
"bun scripts/cli.ts codex deploy <commitId> # disabled legacy deployment entry",
"bun scripts/cli.ts codex prompt-lint [prompt|--prompt-file path|--prompt-stdin]",
"bun scripts/cli.ts codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue id] [--model model] [--dry-run]",
"cat <<'PROMPT' | bun scripts/cli.ts codex submit --prompt-stdin --queue <id> --dry-run",
"bun scripts/cli.ts codex submit --prompt-file /tmp/code-queue-prompt.md --queue <id> --dry-run",
@@ -276,6 +278,7 @@ function codexHelp(): unknown {
disclosure: "Full prompt, tool logs, and feedback prompts are not printed by codex read; use codex task/detail/trace/output for progressive disclosure.",
},
examples: {
promptLint: "bun scripts/cli.ts codex prompt-lint --prompt-file /tmp/code-queue-prompt.md",
stdin: [
"cat <<'PROMPT' | bun scripts/cli.ts codex submit --prompt-stdin --queue <id> --dry-run",
"<multi-line prompt body>",
@@ -300,6 +303,13 @@ function codexHelp(): unknown {
redline: "data.supervisor.activeRunning.redline names the count field, routine target, burst redline, hard redline, and decisionReady flag.",
limitSemantics: "filters.requestedLimit preserves the user input; filters.limit/effectiveLimit shows the capped query budget; section outputBudget/rowPage show returned-row caps.",
},
promptLiveAuthorization: {
classes: ["read-only", "live-read", "live-mutating"],
defaultWhenMissing: "read-only",
command: "bun scripts/cli.ts codex prompt-lint --prompt-file <path>",
embeddedIn: ["codex submit --dry-run", "codex steer --dry-run"],
reference: "docs/reference/code-queue-supervision.md#dev-测试授权分级",
},
description: "Operate Code Queue through the stable backend-core private proxy path with bounded activity summaries for queue and supervisor views. Real submit/steer success is a low-noise write confirmation and does not echo prompt text; terminal steer rejection returns compact status plus codex task/read/submit follow-up commands.",
};
}