From 21b82d7bd791df6f52beb01697ea64f903d88dfa Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 17:56:16 +0000 Subject: [PATCH 01/12] Share semantic audit admission across managed scans --- .../codex-security/mcp-app/helpers-main.ts | 61 +-- .../mcp-app/scripts/build_mcp_app.mjs | 1 + .../mcp-app/src/artifact-scan-draft.ts | 104 +++-- .../src/deep-scan/artifact-validation.ts | 11 +- .../mcp-app/src/deep-scan/worker-runner.ts | 54 ++- .../tests/test_audit_acceptance_contract.mjs | 122 ++++++ sdk/typescript/scripts/check-package.mjs | 1 + sdk/typescript/src/accepted-audit.ts | 49 +++ sdk/typescript/src/api.ts | 214 ++++++---- .../tests-ts/api-audit-admission.test.ts | 395 ++++++++++++++++++ sdk/typescript/tests-ts/build-plugin.test.ts | 30 ++ 11 files changed, 868 insertions(+), 174 deletions(-) create mode 100644 plugins/codex-security/mcp-app/tests/test_audit_acceptance_contract.mjs create mode 100644 sdk/typescript/src/accepted-audit.ts create mode 100644 sdk/typescript/tests-ts/api-audit-admission.test.ts diff --git a/plugins/codex-security/mcp-app/helpers-main.ts b/plugins/codex-security/mcp-app/helpers-main.ts index 18c8611be..7e318d987 100644 --- a/plugins/codex-security/mcp-app/helpers-main.ts +++ b/plugins/codex-security/mcp-app/helpers-main.ts @@ -1,34 +1,43 @@ +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +export { parseCanonicalScanDraft } from "./src/artifact-scan-draft.js"; import { resolveSecurityMdCommand } from "./src/helpers/resolve-security-md"; import { decodePosixBytes } from "./src/helpers/posix-path"; import { windowsBinding } from "./src/native"; -let commandLine = process.argv.slice(2); -if (process.platform === "win32") { - const original = windowsBinding().windowsArguments(); - commandLine = original - .slice(original.length - commandLine.length) - .map((argument) => argument.toString("utf16le")); -} -let posixHome = process.env.HOME; -if (commandLine[0] === "--helper") { +// Importing the bundled helper from the SDK does not invoke its CLI adapter. +const entryPath = import.meta.url.startsWith("file:") ? fileURLToPath(import.meta.url) : import.meta.url; +if (process.argv[1] && resolve(process.argv[1]) === entryPath) runHelper(); + +function runHelper(): void { + let commandLine = process.argv.slice(2); if (process.platform === "win32") { - commandLine = commandLine.slice(1); + const original = windowsBinding().windowsArguments(); + commandLine = original + .slice(original.length - commandLine.length) + .map((argument) => argument.toString("utf16le")); + } + let posixHome = process.env.HOME; + if (commandLine[0] === "--helper") { + if (process.platform === "win32") { + commandLine = commandLine.slice(1); + } else { + const [homeSet, home, ...args] = decodePosixBytes( + Buffer.from(commandLine[1] ?? "", "hex"), + ) + .split("\0") + .slice(0, -1); + posixHome = homeSet ? home : undefined; + commandLine = args; + } + } + const [command, ...args] = commandLine; + if (command === "resolve-security-md") { + process.exitCode = resolveSecurityMdCommand(args, posixHome); } else { - const [homeSet, home, ...args] = decodePosixBytes( - Buffer.from(commandLine[1] ?? "", "hex"), - ) - .split("\0") - .slice(0, -1); - posixHome = homeSet ? home : undefined; - commandLine = args; + console.error( + "Usage: launch_codex_security_mcp[.cmd] --helper resolve-security-md [options]", + ); + process.exitCode = 2; } } -const [command, ...args] = commandLine; -if (command === "resolve-security-md") { - process.exitCode = resolveSecurityMdCommand(args, posixHome); -} else { - console.error( - "Usage: launch_codex_security_mcp[.cmd] --helper resolve-security-md [options]", - ); - process.exitCode = 2; -} diff --git a/plugins/codex-security/mcp-app/scripts/build_mcp_app.mjs b/plugins/codex-security/mcp-app/scripts/build_mcp_app.mjs index ec49e0305..e8e636e6b 100644 --- a/plugins/codex-security/mcp-app/scripts/build_mcp_app.mjs +++ b/plugins/codex-security/mcp-app/scripts/build_mcp_app.mjs @@ -109,5 +109,6 @@ const runtimeModule = new Module(loaderPath); runtimeModule.filename = loaderPath; runtimeModule.paths = Module._nodeModulePaths(dirname(loaderPath)); runtimeModule._compile(runtimeSource, loaderPath); +export default runtimeModule.exports; `; } diff --git a/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts b/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts index 4c560822c..fde9325c0 100644 --- a/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts +++ b/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts @@ -4,6 +4,7 @@ import { dirname, join, sep } from "node:path"; import type * as z from "zod/v4"; import commonSchema from "../../schemas/definitions/artifact-common.schema.json"; import scanDraftDocument from "../../schemas/tools/scan-draft.schema.json"; +import scanManifestDocument from "../../schemas/scan-manifest.schema.json"; import type { ArtifactContext } from "./artifact-context.js"; import type { RunArtifactWorkbench } from "./artifact-context.js"; import { @@ -17,17 +18,10 @@ import { type SchemaDocument, } from "./artifact-schema-loader.js"; -type JsonObject = Record; +import type { ScanDraftInput } from "../../../../sdk/typescript/src/accepted-audit.js"; +export type { ScanDraftInput } from "../../../../sdk/typescript/src/accepted-audit.js"; -export interface ScanDraftInput { - scanId: string; - complete?: boolean; - handoffClaimToken?: string; - scope?: JsonObject; - threatModel?: JsonObject; - findings: JsonObject[]; - coverage: JsonObject; -} +type JsonObject = Record; export interface CompletedScanInput { scanId: string; @@ -69,6 +63,19 @@ export const scanDraftInputSchema = loadArtifactZodSchema( "scanDraftInput", ) as z.ZodType; +// Sealed documents retain the existing public ID contract; live drafts require UUIDs. +const canonicalScanDraftInputSchema = loadArtifactZodSchema( + [commonSchema, { + ...scanDraftDocument, + $defs: { + ...scanDraftDocument.$defs, + scanId: scanManifestDocument.properties.scan.properties.id, + }, + }] as SchemaDocument[], + scanDraftDocument.$id, + "scanDraftInput", +) as z.ZodType; + export const completedScanInputSchema = loadArtifactZodSchema( schemaDocuments, scanDraftDocument.$id, @@ -548,32 +555,9 @@ async function readPreviousScanDraft( const manifest = parseJsonObject(contents[0]!, "previous scan draft manifest"); const findings = parseJsonObject(contents[1]!, "previous scan draft findings"); const coverage = parseJsonObject(contents[2]!, "previous scan draft coverage"); - const scan = requireObject(manifest.scan, "previous scan draft.scan"); - const semanticScope = isObject(scan.scope) ? { ...scan.scope } : undefined; - if (semanticScope) { - delete semanticScope.includePaths; - delete semanticScope.excludePaths; - } - const semanticCoverage = { ...coverage }; - for (const field of ["documentType", "schemaVersion", "scanId", "mode", "includePaths", "excludePaths", "receiptRefs", "inventoryStrategy"]) delete semanticCoverage[field]; return { digest, - input: parsePersistedScanDraft({ - scanId: context.scanId, - ...(scan.complete === false ? { complete: false } : {}), - ...(semanticScope && Object.keys(semanticScope).length > 0 - ? { scope: semanticScope } - : {}), - ...(isObject(scan.threatModel) - ? { threatModel: structuredClone(scan.threatModel) } - : {}), - findings: (findings.findings as JsonObject[]).map((finding) => { - const semantic = { ...finding }; - for (const field of ["findingId", "occurrenceId", "fingerprints"]) delete semantic[field]; - return semantic; - }), - coverage: semanticCoverage, - }), + input: parseCanonicalScanDraft({ scanId: context.scanId, manifest, findings, coverage }), }; } @@ -973,8 +957,35 @@ export async function getCodexSecurityCompletedScan( return { scanId: parsed.scanId, manifest, findings, coverage }; } +/** Project canonical documents through the same semantic parser as worker drafts. */ +export function parseCanonicalScanDraft(input: { + scanId?: string; + manifest: JsonObject; + findings: JsonObject; + coverage: JsonObject; +}): ScanDraftInput { + const scan = requireObject(input.manifest.scan, "scan draft manifest.scan"); + for (const scanId of [scan.id, input.findings.scanId, input.coverage.scanId]) { + if (scanId !== undefined && scanId !== input.scanId) { + throw new Error("scan draft: canonical documents belong to a different scan."); + } + } + return parsePersistedCheckpoint({ + scanId: input.scanId, + ...(scan.complete === undefined ? {} : { complete: scan.complete }), + ...(scan.scope === undefined ? {} : { scope: scan.scope }), + ...(scan.threatModel === undefined ? {} : { threatModel: scan.threatModel }), + findings: input.findings.findings, + coverage: input.coverage, + }, canonicalScanDraftInputSchema); +} + export function parseScanDraft(input: ScanDraftInput): ScanDraftInput { - const parsed = scanDraftInputSchema.parse(input); + return parseSemanticScanDraft(input, scanDraftInputSchema); +} + +function parseSemanticScanDraft(input: unknown, schema: z.ZodType): ScanDraftInput { + const parsed = schema.parse(input); validateFindingSemantics(parsed.findings); validateCoverageSemantics(parsed.coverage); return parsed; @@ -984,18 +995,23 @@ export function parseScanDraft(input: ScanDraftInput): ScanDraftInput { export function parsePersistedScanDraft( input: Record ): ScanDraftInput { + return parsePersistedDraft(input, scanDraftInputSchema); +} + +function parsePersistedDraft(input: Record, schema: z.ZodType): ScanDraftInput { const compatible = structuredClone(input); - if (!Array.isArray(compatible.findings)) { - return parseScanDraft(compatible as unknown as ScanDraftInput); - } - for (const finding of compatible.findings) { - if (!isObject(finding)) continue; - normalizePersistedFindingDetails(finding); + if (Array.isArray(compatible.findings)) { + for (const finding of compatible.findings) { + if (isObject(finding)) normalizePersistedFindingDetails(finding); + } } - return parseScanDraft(compatible as unknown as ScanDraftInput); + return parseSemanticScanDraft(compatible, schema); } -function parsePersistedCheckpoint(input: Record): ScanDraftInput { +function parsePersistedCheckpoint( + input: Record, + schema = scanDraftInputSchema, +): ScanDraftInput { const compatible = structuredClone(input); if (isObject(compatible.scope)) { delete compatible.scope.includePaths; @@ -1022,7 +1038,7 @@ function parsePersistedCheckpoint(input: Record): ScanDraftInpu delete finding.fingerprints; } } - return parsePersistedScanDraft(compatible); + return parsePersistedDraft(compatible, schema); } function normalizePersistedFindingDetails(finding: JsonObject): void { diff --git a/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts b/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts index 291557a9e..0909f0f07 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts @@ -50,6 +50,16 @@ export async function validateDiscoveryArtifacts( artifacts: DeepScanArtifacts, resultPath: string, expectedScanId: string +): Promise { + const result = await readDiscoveryAuditDraft(artifacts, resultPath, expectedScanId); + if (result.complete === false) throw new Error("Standard scan worker wrote only a checkpoint; its audit is not complete."); + return result; +} + +export async function readDiscoveryAuditDraft( + artifacts: DeepScanArtifacts, + resultPath: string, + expectedScanId: string, ): Promise { await requireRegularFile(resultPath, artifacts.workersRoot); const result = parseStoredScanDraft( @@ -58,7 +68,6 @@ export async function validateDiscoveryArtifacts( expectedScanId, parsePersistedScanDraft ); - if (result.complete === false) throw new Error("Standard scan worker wrote only a checkpoint; its audit is not complete."); return result; } diff --git a/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts b/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts index 82f119979..2457bbcdd 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts @@ -1,9 +1,11 @@ +import type { ScanDraftInput } from "../artifact-scan-draft.js"; +import { auditEvidence, runAcceptedAudit } from "../../../../../sdk/typescript/src/accepted-audit.js"; import { createHash } from "node:crypto"; import { promises as fs } from "node:fs"; import { dirname, join } from "node:path"; import { getCodexSecurityDeepReducerInputs } from "../artifact-deep-reducer.js"; import { - validateDiscoveryArtifacts, + readDiscoveryAuditDraft, validateReducerArtifacts } from "./artifact-validation.js"; import type { DeepReductionInput, ReducerArtifactValidation } from "./artifact-validation.js"; @@ -185,8 +187,9 @@ export class DeepScanWorkerRunner { artifactContext: { root: artifactDir, layout: "worker" }, subagents: run.config.subagents, validate: async () => { - await validateDiscoveryArtifacts(artifacts, files.resultPath, run.scanId); - discoveryValidated = true; + const draft = await readDiscoveryAuditDraft(artifacts, files.resultPath, run.scanId); + discoveryValidated = draft.complete !== false; + return draft; }, beforeRetry: async (attempt) => { await archiveDirectory( @@ -461,7 +464,7 @@ export class DeepScanWorkerRunner { artifactDir: string; artifactContext?: CodexWorkerArtifactContext; subagents: number; - validate: () => Promise; + validate: () => Promise; beforeRetry: (attempt: number) => Promise; }): Promise { const { run, signal } = this.options; @@ -497,7 +500,7 @@ export class DeepScanWorkerRunner { attempt }); try { - const result = await this.options.executor.run({ + const execute = () => this.options.executor.run({ kind: input.kind, promptPath: executionPromptPath, // Discovery workers write only to their isolated directory. Setup and @@ -526,19 +529,36 @@ export class DeepScanWorkerRunner { }); } }); - if (signal.aborted) { - return await this.cancelAttempt(input, attempt, activeThreadId, attemptPromptPaths); - } - validationStarted = true; - try { - await input.validate(); - } catch (validationError) { - throw withWorkerDiagnostics(validationError, result.diagnostics); - } - validationCompleted = true; - if (signal.aborted) { - return await this.cancelAttempt(input, attempt, activeThreadId, attemptPromptPaths); + const accept = async (result: Awaited>) => { + validationStarted = true; + let accepted: ScanDraftInput | void; + try { + accepted = await input.validate(); + } catch (validationError) { + throw withWorkerDiagnostics(validationError, result.diagnostics); + } + validationCompleted = accepted?.complete !== false; + return accepted === undefined ? {} : auditEvidence(accepted); + }; + // Reducers keep their aggregate contract; discovery uses the shared audit. + const audit = input.kind === "discovery" + ? await runAcceptedAudit({ signal, execute, accept }) + : undefined; + let result: Awaited>; + if (audit) { + if (audit.status === "checkpoint") { + throw withWorkerDiagnostics( + new Error("Standard scan worker wrote only a checkpoint; its audit is not complete."), + audit.execution.diagnostics, + ); + } + result = audit.execution; + } else { + result = await execute(); + if (signal.aborted) return await this.cancelAttempt(input, attempt, activeThreadId, attemptPromptPaths); + await accept(result); } + if (signal.aborted) return await this.cancelAttempt(input, attempt, activeThreadId, attemptPromptPaths); this.options.log({ event: "worker_succeeded", scanId: run.scanId, diff --git a/plugins/codex-security/mcp-app/tests/test_audit_acceptance_contract.mjs b/plugins/codex-security/mcp-app/tests/test_audit_acceptance_contract.mjs new file mode 100644 index 000000000..8d1e108d1 --- /dev/null +++ b/plugins/codex-security/mcp-app/tests/test_audit_acceptance_contract.mjs @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, readdir, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { build } from "esbuild"; + +const bundle = await build({ + stdin: { + contents: `export * from "./src/artifact-scan-draft.ts"; + export * from "./src/deep-scan/artifact-validation.ts"; + export * from "./src/deep-scan/artifacts.ts"; + export * from "../../../sdk/typescript/src/accepted-audit.ts";`, + resolveDir: path.resolve(import.meta.dirname, ".."), + }, + bundle: true, format: "esm", platform: "node", write: false, + footer: { js: "//# sourceURL=audit-acceptance-contract.js" }, +}); +const { + createDeepScanArtifacts, recordCodexSecurityScanDraft, + recordCodexSecurityWorkerScanDraft, validateDiscoveryArtifacts, + readDiscoveryAuditDraft, auditEvidence, runAcceptedAudit, +} = await import(`data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].contents).toString("base64")}`); + +const scanId = "811aef98-3709-4c2d-8b7a-742977521865"; +const finding = { + ruleId: "path-traversal.archive-extraction", title: "Unsafe archive extraction", + summary: "An archive entry reaches a filesystem write.", + severity: { level: "high" }, confidence: { level: "high", rationale: "Source review." }, + taxonomy: { category: "path-traversal", cwe: ["CWE-22"] }, + locations: [{ path: "src/extract.py", startLine: 4 }], + remediation: "Validate the resolved output path before writing.", + provenance: { source: "local_plugin", candidateId: "archive-entry" }, +}; + +for (const completeness of ["complete", "partial", "unknown"]) { + test(`Standard and Deep retain accepted semantic evidence with ${completeness} coverage`, async () => { + const root = await realpath(await mkdtemp(path.join(tmpdir(), "audit-contract-"))); + try { + const repository = path.join(root, "repository"); + const scanDir = path.join(root, "scan"); + const artifacts = createDeepScanArtifacts(scanDir); + const workerRoot = path.join(artifacts.workersRoot, "discovery-0001", "output"); + await Promise.all([mkdir(repository), mkdir(workerRoot, { recursive: true })]); + const semantic = { + scanId, complete: true, + threatModel: { summary: "An untrusted caller supplies archive entries." }, + findings: [finding], + coverage: { + completeness, + surfaces: [{ id: "archive", label: "Archive extraction", disposition: "reported", receiptRefs: [] }], + explicitExclusions: [], + deferred: completeness === "complete" ? [] : [{ id: "deployment-controls", reason: "Deployment controls remain unverified." }], + }, + }; + const standard = { + root: scanDir, repoRoot: repository, layout: "scan", scanId, + mode: "standard", status: "running", scope: ".", + targetContract: { + target: { + allowedKinds: ["directory_snapshot"], targetId: "target_example", displayName: "example", + requiredSnapshotDigest: `codex-security-snapshot/v1:sha256:${"a".repeat(64)}`, + }, + scope: { requiredIncludePaths: ["."], requiredExcludePaths: [] }, diffTarget: null, + }, + }; + const worker = { root: workerRoot, repoRoot: repository, layout: "worker", scanId }; + const checkpoint = { ...semantic, complete: false }; + await recordCodexSecurityScanDraft(standard, checkpoint); + await recordCodexSecurityWorkerScanDraft(worker, checkpoint); + await assert.rejects(validateDiscoveryArtifacts(artifacts, path.join(workerRoot, "result.json"), scanId), /checkpoint/); + assert.equal(JSON.parse(await readFile(path.join(scanDir, "scan-manifest.json"))).scan.complete, false); + const controller = new AbortController(); + const execute = async () => ({ threadId: "audit-conversation", usage: null }); + const accept = async () => auditEvidence(await readDiscoveryAuditDraft( + artifacts, path.join(workerRoot, "result.json"), scanId, + )); + const unfinished = await runAcceptedAudit({ signal: controller.signal, execute, accept }); + assert.equal(unfinished.status, "checkpoint"); + assert.equal(unfinished.checkpoint.complete, false); + assert.equal(unfinished.accepted, undefined); + assert.equal(unfinished.execution.usage, null); + const standardWrite = await recordCodexSecurityScanDraft(standard, semantic); + const deepWrite = await recordCodexSecurityWorkerScanDraft(worker, semantic); + assert.equal(standardWrite.status, "draft_written"); + assert.equal(deepWrite.status, "draft_written"); + const accepted = await validateDiscoveryArtifacts(artifacts, path.join(workerRoot, "result.json"), scanId); + const manifest = JSON.parse(await readFile(path.join(scanDir, "scan-manifest.json"))); + const findings = JSON.parse(await readFile(path.join(scanDir, "findings.json"))); + const coverage = JSON.parse(await readFile(path.join(scanDir, "coverage.json"))); + assert.deepEqual(accepted.findings, [finding]); + for (const [key, value] of Object.entries(finding)) { + assert.deepEqual(findings.findings[0][key], value); + } + assert.ok(findings.findings[0].identity.anchor); + assert.deepEqual(accepted.threatModel, manifest.scan.threatModel); + for (const field of ["completeness", "surfaces", "explicitExclusions", "deferred"]) { + assert.deepEqual(coverage[field], accepted.coverage[field]); + } + assert.equal(accepted.scanId, scanId); + const audit = await runAcceptedAudit({ signal: controller.signal, execute, accept }); + assert.equal(audit.status, "accepted"); + assert.deepEqual(audit.accepted, accepted); + assert.deepEqual(audit.checkpoint, accepted); + const failure = new Error("Synthetic execution failure"); + await assert.rejects(runAcceptedAudit({ signal: controller.signal, + execute: async () => { throw failure; }, + accept: async () => { assert.fail("An execution failure cannot accept old output"); }, + }), (error) => error === failure); + await assert.rejects(runAcceptedAudit({ signal: controller.signal, execute, + accept: async () => { const evidence = await accept(); controller.abort("user canceled"); return evidence; }, + }), (error) => error === controller.signal.reason); + assert.equal(manifest.scan.sealedAt, undefined); + assert.equal(manifest.scan.artifacts, undefined); + assert.equal((await readdir(workerRoot)).includes("scan-manifest.json"), false); + assert.equal((await readdir(scanDir)).includes("report.md"), false); + await assert.rejects(validateDiscoveryArtifacts(artifacts, path.join(workerRoot, "result.json"), "b4c84677-5aaf-410c-88d2-3e97e6f8c4d8"), /scan/); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +} diff --git a/sdk/typescript/scripts/check-package.mjs b/sdk/typescript/scripts/check-package.mjs index 8fd657e58..4fc952630 100644 --- a/sdk/typescript/scripts/check-package.mjs +++ b/sdk/typescript/scripts/check-package.mjs @@ -166,6 +166,7 @@ const allowedRoot = new Set([ ]); const distFiles = new Set( [ + "accepted-audit", "api", "auth", "bulk-scan-discovery", diff --git a/sdk/typescript/src/accepted-audit.ts b/sdk/typescript/src/accepted-audit.ts new file mode 100644 index 000000000..ee526a955 --- /dev/null +++ b/sdk/typescript/src/accepted-audit.ts @@ -0,0 +1,49 @@ +export interface ScanDraftInput { + scanId: string; + complete?: boolean; + handoffClaimToken?: string; + scope?: Record; + threatModel?: Record; + findings: Record[]; + coverage: Record; +} + +/** Accepted evidence may still describe partial or unknown source coverage. */ +export interface AuditEvidence { + checkpoint?: ScanDraftInput; + accepted?: ScanDraftInput; +} + +export type AuditOutcome = AuditEvidence & + ( + | { status: "accepted"; execution: Execution; accepted: ScanDraftInput } + | { status: "checkpoint"; execution: Execution } + ); + +/** One attempt; enclosing callers own retries and public completion. */ +export async function runAcceptedAudit(input: { + signal: AbortSignal; + execute: () => Promise; + accept: (execution: Execution) => Promise; +}): Promise> { + input.signal.throwIfAborted(); + const execution = await input.execute(); + input.signal.throwIfAborted(); + const evidence = await input.accept(execution); + input.signal.throwIfAborted(); + return evidence.accepted === undefined + ? { ...evidence, execution, status: "checkpoint" } + : { + ...evidence, + execution, + status: "accepted", + accepted: evidence.accepted, + }; +} + +/** Process completion alone does not accept an unfinished audit checkpoint. */ +export function auditEvidence(checkpoint: ScanDraftInput): AuditEvidence { + return checkpoint.complete === false + ? { checkpoint } + : { checkpoint, accepted: checkpoint }; +} diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index db2b34c93..b8b014925 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -1,5 +1,11 @@ /// +import { + auditEvidence, + runAcceptedAudit, + type ScanDraftInput, +} from "./accepted-audit.js"; +import { pathToFileURL } from "node:url"; import { statSync } from "node:fs"; import { chmod, @@ -1914,6 +1920,7 @@ export class CodexSecurity { events, signal, scanDir, + scanId, pluginRoot: runtime.plugin.installedRoot, expectation, authentication, @@ -2143,6 +2150,7 @@ export class CodexSecurity { events: (await followUp()).events, signal, scanDir, + scanId, pluginRoot: runtime.plugin.installedRoot, expectation, model, @@ -3596,6 +3604,7 @@ async function removeTargetPathsFile(path: string | null): Promise { } interface ScanEventRunOptions { + scanId?: string; thread: CodexThreadLike; events: AsyncGenerator; signal: AbortSignal; @@ -3629,108 +3638,141 @@ export async function runScanEvents( let scanStarted = false; let tacStatusReported = false; try { - const turn = await readCodexTurn({ - thread: options.thread, - events: options.events, - onEvent: async (event) => { - if (!tacStatusReported) { - const tacStatus = trustedAccessStatusFromEvent(event); - if (tacStatus !== null) { - tacStatusReported = true; - notifyObserver( - "onTrustedAccessStatus", - options.onTrustedAccessStatus, - options.onObserverError, - tacStatus, - ); - if (tacStatus !== "granted") { + const execute = async () => { + const turn = await readCodexTurn({ + thread: options.thread, + events: options.events, + onEvent: async (event) => { + if (!tacStatusReported) { + const tacStatus = trustedAccessStatusFromEvent(event); + if (tacStatus !== null) { + tacStatusReported = true; notifyObserver( - "onWarning", - options.onWarning, + "onTrustedAccessStatus", + options.onTrustedAccessStatus, options.onObserverError, - trustedAccessWarning(tacStatus, options.authentication), + tacStatus, ); + if (tacStatus !== "granted") { + notifyObserver( + "onWarning", + options.onWarning, + options.onObserverError, + trustedAccessWarning(tacStatus, options.authentication), + ); + } } } - } - for (const activity of scanActivitiesFromEvent( - event, - options.expectation.repository, - )) { - notifyObserver( - "onActivity", - options.onActivity, - options.onObserverError, - activity, - ); - } - for (const progress of scanProgressUpdatesFromEvent(event)) { - if ( - options.expectedFilesTotal !== undefined && - progress.filesTotal !== options.expectedFilesTotal - ) { - continue; + for (const activity of scanActivitiesFromEvent( + event, + options.expectation.repository, + )) { + notifyObserver( + "onActivity", + options.onActivity, + options.onObserverError, + activity, + ); } - notifyObserver( - "onProgress", - options.onProgress, - options.onObserverError, - progress, - ); - } - const workerStatus = workerStatusFromEvent(event); - if (workerStatus !== null) { - notifyObserver( - "onWorkerStatus", - options.onWorkerStatus, - options.onObserverError, - workerStatus, - ); - } - if (event.type === "thread.started") { - const startedThreadId = event["thread_id"]; - if (typeof startedThreadId === "string") { - await options.onThreadStarted?.(startedThreadId); + for (const progress of scanProgressUpdatesFromEvent(event)) { + if ( + options.expectedFilesTotal !== undefined && + progress.filesTotal !== options.expectedFilesTotal + ) { + continue; + } + notifyObserver( + "onProgress", + options.onProgress, + options.onObserverError, + progress, + ); } - if (!scanStarted) { - scanStarted = true; + const workerStatus = workerStatusFromEvent(event); + if (workerStatus !== null) { notifyObserver( - "onScanStarted", - options.onScanStarted, + "onWorkerStatus", + options.onWorkerStatus, options.onObserverError, + workerStatus, ); } - } - }, - onReconnect: (message, reconnect) => { - notifyObserver( - "onReconnect", - options.onReconnect, - options.onObserverError, - ...reconnect, - reconnectDetails(message), + if (event.type === "thread.started") { + const startedThreadId = event["thread_id"]; + if (typeof startedThreadId === "string") { + await options.onThreadStarted?.(startedThreadId); + } + if (!scanStarted) { + scanStarted = true; + notifyObserver( + "onScanStarted", + options.onScanStarted, + options.onObserverError, + ); + } + } + }, + onReconnect: (message, reconnect) => { + notifyObserver( + "onReconnect", + options.onReconnect, + options.onObserverError, + ...reconnect, + reconnectDetails(message), + ); + }, + }); + const { status, threadId, lastStreamError } = turn; + if (status !== "completed") { + throw new IncompleteScanError( + lastStreamError ?? + "Codex Security event stream ended before the turn completed.", ); - }, - }); - const { status, threadId, finalResponse, lastStreamError } = turn; - let { usage } = turn; - if (options.signal.aborted) { - throw new ScanInterruptedError( - `Codex Security scan was interrupted; partial output remains at ${options.scanDir}.`, - options.scanDir, - ); - } - if (status !== "completed") { - throw new IncompleteScanError( - lastStreamError ?? - "Codex Security event stream ended before the turn completed.", + } + if (threadId === null) { + throw new IncompleteScanError( + "Codex Security did not report a thread ID.", + ); + } + return { ...turn, threadId, status }; + }; + const accept = async () => { + // Matching, custom validation and the canonical seal remain with the caller. + const [manifest, findings, coverage] = await Promise.all( + ["scan-manifest.json", "findings.json", "coverage.json"].map( + async (name) => + JSON.parse( + ( + await readScanFile(options.scanDir, name, name, options.signal) + ).toString("utf8"), + ), + ), ); - } - if (threadId === null) { + const helper = ( + await import( + pathToFileURL(join(options.pluginRoot, "mcp/helpers.mjs")).href + ) + ).default; + const draft: ScanDraftInput = helper.parseCanonicalScanDraft({ + scanId: options.scanId ?? manifest.scan.id, + manifest, + findings, + coverage, + }); + return auditEvidence(draft); + }; + const audit = await runAcceptedAudit({ + signal: options.signal, + execute, + accept, + }); + if (audit.status === "checkpoint") { throw new IncompleteScanError( - "Codex Security did not report a thread ID.", + "Codex Security produced only an unfinished audit checkpoint.", ); } + const { status, threadId, finalResponse } = audit.execution; + let { usage } = audit.execution; if (options.onFinalize !== undefined) { usage = (await options.onFinalize(usage)) ?? usage; } diff --git a/sdk/typescript/tests-ts/api-audit-admission.test.ts b/sdk/typescript/tests-ts/api-audit-admission.test.ts new file mode 100644 index 000000000..0bfc8c269 --- /dev/null +++ b/sdk/typescript/tests-ts/api-audit-admission.test.ts @@ -0,0 +1,395 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { afterEach, expect, test } from "bun:test"; +import { build } from "esbuild"; +import { runScanEvents } from "../src/api.js"; +import type { ScanDraftInput } from "../src/accepted-audit.js"; +import { PLUGIN_ROOT } from "./plugin-root.js"; +import { + completedEvents, + createApiTestFixtures, +} from "./support/api-events.js"; + +const { temporaryDirectory, copyCompletedScan, cleanup } = + createApiTestFixtures(); +afterEach(cleanup); + +const bundle = await build({ + stdin: { + resolveDir: fileURLToPath( + new URL("../../../plugins/codex-security/mcp-app/", import.meta.url), + ), + contents: `export * from "./src/artifact-scan-draft.ts"; + export * from "./src/deep-scan/artifacts.ts"; + export * from "./src/deep-scan/artifact-validation.ts"; + export * from "./src/deep-scan/worker-runner.ts";`, + }, + bundle: true, + format: "esm", + platform: "node", + loader: { ".md": "text" }, + write: false, +}); +const bundlePath = join(await temporaryDirectory(), "deep-admission.mjs"); +await writeFile(bundlePath, bundle.outputFiles[0]!.contents); +const { + createDeepScanArtifacts, + recordCodexSecurityScanDraft, + readDiscoveryAuditDraft, + DeepScanWorkerRunner, +} = await import(pathToFileURL(bundlePath).href); + +const scanId = "811aef98-3709-4c2d-8b7a-742977521865"; +type Mutation = + | "missing-findings" + | "contradictory-coverage" + | "inverted-lines" + | "wrong-scan" + | "legacy-details"; +const cases: { + name: string; + coverage: "complete" | "partial" | "unknown"; + complete?: boolean; + mutation?: Mutation; + accepted: boolean; +}[] = [ + { + name: "complete coverage", + coverage: "complete", + complete: true, + accepted: true, + }, + { + name: "partial coverage", + coverage: "partial", + complete: true, + accepted: true, + }, + { + name: "unknown coverage", + coverage: "unknown", + complete: true, + accepted: true, + }, + { name: "omitted completion marker", coverage: "partial", accepted: true }, + { + name: "persisted legacy details", + coverage: "partial", + mutation: "legacy-details", + accepted: true, + }, + { + name: "unfinished checkpoint", + coverage: "partial", + complete: false, + accepted: false, + }, + { + name: "missing findings", + coverage: "partial", + mutation: "missing-findings", + accepted: false, + }, + { + name: "complete coverage with deferred work", + coverage: "partial", + mutation: "contradictory-coverage", + accepted: false, + }, + { + name: "inverted finding lines", + coverage: "partial", + mutation: "inverted-lines", + accepted: false, + }, + { + name: "mismatched canonical scan ID", + coverage: "partial", + mutation: "wrong-scan", + accepted: false, + }, +]; + +for (const scenario of cases) { + test(`Standard and Deep production admission: ${scenario.name}`, async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const standardRoot = join(root, "standard"); + const deepRoot = join(root, "deep"); + await Promise.all([ + mkdir(repository), + mkdir(standardRoot, { mode: 0o700 }), + mkdir(deepRoot, { mode: 0o700 }), + ]); + const semantic: ScanDraftInput = { + scanId, + ...(scenario.complete === undefined + ? {} + : { complete: scenario.complete }), + scope: { summary: "Archive extraction." }, + threatModel: { summary: "An untrusted caller supplies archive entries." }, + findings: [ + { + ruleId: "path-traversal.archive", + title: "Unsafe archive extraction", + summary: "An archive entry reaches a filesystem write.", + severity: { level: "high" }, + confidence: { level: "high", rationale: "Source review." }, + taxonomy: { category: "path-traversal", cwe: ["CWE-22"] }, + locations: [{ path: "extract.py", startLine: 4, endLine: 7 }], + remediation: "Validate the resolved output path before writing.", + provenance: { source: "local_plugin", candidateId: "archive-entry" }, + }, + ], + coverage: { + completeness: scenario.coverage, + surfaces: [{ label: "Archive extraction", disposition: "reported" }], + explicitExclusions: [], + deferred: + scenario.coverage === "complete" + ? [] + : [ + { + id: "deployment", + reason: "Deployment controls remain unverified.", + }, + ], + }, + }; + await recordCodexSecurityScanDraft( + { + root: standardRoot, + repoRoot: repository, + layout: "scan", + scanId, + mode: "standard", + status: "running", + scope: ".", + targetContract: { + target: { + allowedKinds: ["directory_snapshot"], + targetId: "target_example", + displayName: "example", + requiredSnapshotDigest: `codex-security-snapshot/v1:sha256:${"a".repeat(64)}`, + }, + scope: { requiredIncludePaths: ["."], requiredExcludePaths: [] }, + diffTarget: null, + }, + }, + semantic, + ); + const submitted = mutateDraft(semantic, scenario.mutation); + const findings = { + scanId: submitted.scanId, + findings: submitted.findings?.map((finding) => ({ + ...finding, + findingId: "finding_example", + occurrenceId: "occurrence_example", + fingerprints: { identity: "synthetic" }, + })), + }; + const coverage = JSON.parse( + await readFile(join(standardRoot, "coverage.json"), "utf8"), + ); + Object.assign(coverage, submitted.coverage); + await Promise.all([ + writeFile(join(standardRoot, "findings.json"), JSON.stringify(findings)), + writeFile(join(standardRoot, "coverage.json"), JSON.stringify(coverage)), + ]); + const standard = await observeStandardAdmission( + root, + repository, + standardRoot, + scanId, + ); + + const artifacts = createDeepScanArtifacts(deepRoot); + const acceptedPaths: string[] = []; + let executions = 0; + const runner = new DeepScanWorkerRunner({ + run: { + scanId, + scanDir: deepRoot, + targetPath: repository, + scope: ".", + config: { subagents: 0 }, + }, + artifacts, + pluginRoot: PLUGIN_ROOT, + signal: new AbortController().signal, + retryDelaysMs: [], + random: () => 0, + log: () => {}, + clock: { now: () => Date.now(), sleep: async () => {} }, + executor: { + async run(request: { + artifactContext: { root: string }; + onThreadStarted?: (id: string) => Promise; + }) { + executions++; + await request.onThreadStarted?.("deep-thread"); + await writeFile( + join(request.artifactContext.root, "result.json"), + JSON.stringify(submitted), + ); + return { threadId: "deep-thread" }; + }, + }, + store: { + async updateWorker(update: { + status: string; + resultManifestPath?: string; + }) { + if (update.status === "succeeded") { + acceptedPaths.push(update.resultManifestPath!); + return { ...update, completionSequence: 1 }; + } + return update; + }, + }, + }); + const deepResult = await runner.runDiscoveryWorker( + "worker-1", + "discovery-1", + ); + expect(executions).toBe(1); + if (scenario.accepted) { + expect(standard.error).toBe(standard.finalization); + expect(standard.finalizations).toBe(1); + expect(standard.drafts).toHaveLength(1); + expect(deepResult.status).toBe("succeeded"); + expect(acceptedPaths).toEqual([deepResult.worker.resultPath]); + const deepDraft: ScanDraftInput = await readDiscoveryAuditDraft( + artifacts, + deepResult.worker.resultPath, + scanId, + ); + expect(standard.drafts[0]!.findings).toEqual(deepDraft.findings); + expect(standard.drafts[0]!.coverage).toEqual(deepDraft.coverage); + expect(standard.drafts[0]!.scope).toEqual(deepDraft.scope); + expect(standard.drafts[0]!.threatModel).toEqual(deepDraft.threatModel); + if (scenario.mutation === "legacy-details") { + expect(deepDraft.findings[0]!["validation"]).toEqual({ + limitations: ["Legacy persisted limitation."], + }); + } + } else { + expect(standard.error).toBeInstanceOf(Error); + expect(standard.error).not.toBe(standard.finalization); + expect(standard.finalizations).toBe(0); + expect(deepResult.status).toBe("failed"); + expect(acceptedPaths).toHaveLength(0); + } + expect(standard.calls).toBe(1); + const manifest = JSON.parse( + await readFile(join(standardRoot, "scan-manifest.json"), "utf8"), + ); + expect(manifest.scan.sealedAt).toBeUndefined(); + await expect(readFile(join(standardRoot, "report.md"))).rejects.toThrow(); + }); +} + +test("Standard admission preserves existing canonical scan IDs", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + await mkdir(repository); + const scanDir = await copyCompletedScan(root); + const manifest = JSON.parse( + await readFile(join(scanDir, "scan-manifest.json"), "utf8"), + ); + const standard = await observeStandardAdmission(root, repository, scanDir); + expect(manifest.scan.id).toBe("scan_example_001"); + expect(standard.error).toBe(standard.finalization); + expect(standard.finalizations).toBe(1); + expect(standard.drafts).toHaveLength(1); + expect(standard.calls).toBe(1); + expect(standard.drafts[0]!.scanId).toBe(manifest.scan.id); +}); + +async function observeStandardAdmission( + root: string, + repository: string, + scanDir: string, + scanId?: string, +) { + const pluginRoot = join(root, "observed-plugin"); + const helperPath = join(pluginRoot, "mcp", "helpers.mjs"); + await mkdir(join(pluginRoot, "mcp"), { recursive: true }); + // The bundled exports are immutable getters. Observe the real parser through + // a local delegator instead of mocking its module or copying its semantics. + await writeFile( + helperPath, + `import helpers from ${JSON.stringify(pathToFileURL(join(PLUGIN_ROOT, "mcp", "helpers.mjs")).href)}; +export const drafts = []; +export let calls = 0; +export default { ...helpers, parseCanonicalScanDraft(input) { + calls++; + const draft = helpers.parseCanonicalScanDraft(input); + drafts.push(draft); + return draft; +} };`, + ); + const observed: { drafts: ScanDraftInput[]; calls: number } = await import( + pathToFileURL(helperPath).href + ); + const finalization = new Error("The enclosing finalizer owns the next step."); + let finalizations = 0; + const error = await runScanEvents({ + scanId, + thread: { + id: "standard-thread", + async runStreamed() { + return { events: completedEvents("standard-thread") }; + }, + }, + events: completedEvents("standard-thread"), + signal: new AbortController().signal, + scanDir, + pluginRoot, + expectation: { + repository, + repositoryRevision: null, + target: { kind: "repository", paths: [] }, + mode: "standard", + pluginVersion: "0.1.0", + }, + onFinalize: async () => { + finalizations++; + throw finalization; + }, + }).catch((error: unknown) => error); + return { + error, + finalization, + finalizations, + drafts: observed.drafts, + calls: observed.calls, + }; +} + +function mutateDraft( + input: ScanDraftInput, + mutation?: Mutation, +): ScanDraftInput { + const draft = structuredClone(input); + if (mutation === "missing-findings") + return { ...draft, findings: undefined } as unknown as ScanDraftInput; + if (mutation === "wrong-scan") + draft.scanId = "553a0c18-dcdf-4a3b-8e39-2751a8187bce"; + if (mutation === "contradictory-coverage") + draft.coverage["completeness"] = "complete"; + if (mutation === "inverted-lines") { + const locations = draft.findings[0]!["locations"] as Record< + string, + unknown + >[]; + locations[0]!["endLine"] = 1; + } + if (mutation === "legacy-details") + draft.findings[0]!["validation"] = { + method: null, + limitations: "Legacy persisted limitation.", + }; + return draft; +} diff --git a/sdk/typescript/tests-ts/build-plugin.test.ts b/sdk/typescript/tests-ts/build-plugin.test.ts index 617498d37..90470e484 100644 --- a/sdk/typescript/tests-ts/build-plugin.test.ts +++ b/sdk/typescript/tests-ts/build-plugin.test.ts @@ -178,6 +178,36 @@ describe("bundled plugin build", () => { ]); expect(helper.stdout).toBe("[]\n"); expect(helper.stderr).toBe(""); + const imported = await execFileAsync( + "node", + [ + "--input-type=module", + "--eval", + ` + import assert from "node:assert/strict"; + import { pathToFileURL } from "node:url"; + const { default: helpers } = await import(pathToFileURL(process.argv[2]).href); + const input = { + scanId: "synthetic-scan", + manifest: { scan: {} }, + findings: { findings: [] }, + coverage: { + completeness: "complete", surfaces: [], explicitExclusions: [], deferred: [], + }, + }; + assert.equal(helpers.parseCanonicalScanDraft(input).scanId, input.scanId); + assert.throws(() => helpers.parseCanonicalScanDraft({ + ...input, coverage: { ...input.coverage, completeness: "invalid" }, + })); + assert.equal(process.exitCode, undefined); + `, + "helper-import-test", + join(destination, "helpers.mjs"), + ], + { cwd: root, env: { ...process.env, NODE_PATH: "" } }, + ); + expect(imported.stdout).toBe(""); + expect(imported.stderr).toBe(""); }); test("builds from a source snapshot without Git metadata", async () => { From 4ee012f21244f24f11118151a0402c375ead6d35 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 18:45:09 +0000 Subject: [PATCH 02/12] test: prepare audit draft before finalization --- sdk/typescript/tests-ts/api-events.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/sdk/typescript/tests-ts/api-events.test.ts b/sdk/typescript/tests-ts/api-events.test.ts index 28d9b1212..2f7d79c73 100644 --- a/sdk/typescript/tests-ts/api-events.test.ts +++ b/sdk/typescript/tests-ts/api-events.test.ts @@ -1,4 +1,4 @@ -import { mkdir, stat } from "node:fs/promises"; +import { mkdir, rm, stat } from "node:fs/promises"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { @@ -477,7 +477,8 @@ describe("one-shot scan events", () => { test("lets the workbench seal artifacts before validating completed scans", async () => { const root = await temporaryDirectory(); - const scanDir = join(root, "scan"); + const scanDir = await copyCompletedScan(root); + await rm(join(scanDir, "report.md")); const events = completedEvents(); let finalized = false; @@ -506,7 +507,8 @@ describe("one-shot scan events", () => { cache_write_input_tokens: 0, output_tokens: 3, }); - expect(existsSync(join(scanDir, "scan-manifest.json"))).toBe(false); + expect(existsSync(join(scanDir, "scan-manifest.json"))).toBe(true); + expect(existsSync(join(scanDir, "report.md"))).toBe(false); await copyCompletedScan(root); finalized = true; }, From b2512b30b97e52377b67ffb63fa49bd1658e45d5 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 18:45:49 +0000 Subject: [PATCH 03/12] Preserve helper CLI behavior through linked plugin paths --- .../codex-security/mcp-app/helpers-main.ts | 8 ++- sdk/typescript/tests-ts/build-plugin.test.ts | 56 +++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/plugins/codex-security/mcp-app/helpers-main.ts b/plugins/codex-security/mcp-app/helpers-main.ts index 7e318d987..4110c9b60 100644 --- a/plugins/codex-security/mcp-app/helpers-main.ts +++ b/plugins/codex-security/mcp-app/helpers-main.ts @@ -1,4 +1,4 @@ -import { resolve } from "node:path"; +import { existsSync, realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; export { parseCanonicalScanDraft } from "./src/artifact-scan-draft.js"; import { resolveSecurityMdCommand } from "./src/helpers/resolve-security-md"; @@ -7,7 +7,11 @@ import { windowsBinding } from "./src/native"; // Importing the bundled helper from the SDK does not invoke its CLI adapter. const entryPath = import.meta.url.startsWith("file:") ? fileURLToPath(import.meta.url) : import.meta.url; -if (process.argv[1] && resolve(process.argv[1]) === entryPath) runHelper(); +const invokedPath = process.argv[1]; +if ( + invokedPath && existsSync(invokedPath) + && realpathSync(invokedPath) === realpathSync(entryPath) +) runHelper(); function runHelper(): void { let commandLine = process.argv.slice(2); diff --git a/sdk/typescript/tests-ts/build-plugin.test.ts b/sdk/typescript/tests-ts/build-plugin.test.ts index 90470e484..fd405916d 100644 --- a/sdk/typescript/tests-ts/build-plugin.test.ts +++ b/sdk/typescript/tests-ts/build-plugin.test.ts @@ -178,6 +178,62 @@ describe("bundled plugin build", () => { ]); expect(helper.stdout).toBe("[]\n"); expect(helper.stderr).toBe(""); + + const repository = await temporaryDirectory(); + const policy = "Preserve this synthetic inherited security policy."; + await writeFixture( + repository, + "SECURITY.md", + `# Synthetic policy\n${policy}\n`, + ); + const alias = join(await temporaryDirectory(), "plugin link"); + await symlink( + root, + alias, + process.platform === "win32" ? "junction" : "dir", + ); + await mkdir(join(root, "scripts"), { recursive: true }); + await copyFile( + new URL("scripts/launch_codex_security_mcp", source), + join(root, "scripts", "launch_codex_security_mcp"), + ); + const node = ( + await execFileAsync("node", ["--print", "process.execPath"]) + ).stdout.trim(); + for (const pluginPath of [root, alias]) { + const linkedHelper = join(pluginPath, "mcp", "helpers.mjs"); + const list = await execFileAsync( + process.platform === "win32" ? node : "/bin/sh", + [ + ...(process.platform === "win32" + ? [linkedHelper] + : [ + join(pluginPath, "scripts", "launch_codex_security_mcp"), + "--helper", + ]), + "resolve-security-md", + "--repo", + repository, + "--list", + ], + { env: { ...process.env, CODEX_MCP_NODE_PATH: node, NODE_PATH: "" } }, + ); + expect(list.stdout).toBe('["SECURITY.md"]\n'); + expect(list.stderr).toBe(""); + const guidance = await execFileAsync(node, [ + linkedHelper, + "resolve-security-md", + "--repo", + repository, + "--scope", + repository, + "--out", + "-", + ]); + expect(guidance.stdout).toContain(policy); + expect(guidance.stderr).toBe(""); + } + const imported = await execFileAsync( "node", [ From 67d586dd699fc5a1cad9afe8f53845bd14ba5842 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 20:13:09 +0000 Subject: [PATCH 04/12] fix: resolve SDK dependencies in native policy proof --- plugins/codex-security/native/proof-policy-windows.mts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/plugins/codex-security/native/proof-policy-windows.mts b/plugins/codex-security/native/proof-policy-windows.mts index 5ca186a39..65235e6af 100644 --- a/plugins/codex-security/native/proof-policy-windows.mts +++ b/plugins/codex-security/native/proof-policy-windows.mts @@ -8,10 +8,11 @@ import { nativeTarget } from "./platform.mjs"; const testDirectory = join(output, "policy-proof"); const helper = join(testDirectory, "helpers.cjs"); if (process.argv[2] === "build") { + const sdkModules = join(root, "../../../sdk/typescript/node_modules"); execFileSync( process.execPath, [ - join(root, "../../../sdk/typescript/node_modules/esbuild/bin/esbuild"), + join(sdkModules, "esbuild/bin/esbuild"), join(root, "../mcp-app/helpers-main.ts"), "--bundle", "--platform=node", @@ -20,7 +21,11 @@ if (process.argv[2] === "build") { "--define:import.meta.url=__filename", `--outfile=${helper}`, ], - { stdio: "inherit" }, + { + stdio: "inherit", + // Native CI installs the helper's dependencies only in the SDK. + env: { ...process.env, NODE_PATH: sdkModules }, + }, ); const nativeDirectory = join(testDirectory, "native", nativeTarget); mkdirSync(nativeDirectory, { recursive: true }); From 6bcec2859e3abc279983e4b5c7d8c73fb8b5de45 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 16 Sep 2026 06:47:00 +0000 Subject: [PATCH 05/12] Classify accepted audit drafts once --- .../mcp-app/src/artifact-scan-draft.ts | 5 +-- .../mcp-app/src/deep-scan/worker-runner.ts | 4 +- .../tests/test_audit_acceptance_contract.mjs | 6 +-- sdk/typescript/src/accepted-audit.ts | 37 ++++++------------- sdk/typescript/src/api.ts | 6 +-- 5 files changed, 21 insertions(+), 37 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts b/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts index fde9325c0..d09f28e37 100644 --- a/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts +++ b/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts @@ -995,11 +995,10 @@ function parseSemanticScanDraft(input: unknown, schema: z.ZodType ): ScanDraftInput { - return parsePersistedDraft(input, scanDraftInputSchema); + return parsePersistedDraft(structuredClone(input), scanDraftInputSchema); } -function parsePersistedDraft(input: Record, schema: z.ZodType): ScanDraftInput { - const compatible = structuredClone(input); +function parsePersistedDraft(compatible: Record, schema: z.ZodType): ScanDraftInput { if (Array.isArray(compatible.findings)) { for (const finding of compatible.findings) { if (isObject(finding)) normalizePersistedFindingDetails(finding); diff --git a/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts b/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts index 2457bbcdd..1bc376eac 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts @@ -1,5 +1,5 @@ import type { ScanDraftInput } from "../artifact-scan-draft.js"; -import { auditEvidence, runAcceptedAudit } from "../../../../../sdk/typescript/src/accepted-audit.js"; +import { runAcceptedAudit } from "../../../../../sdk/typescript/src/accepted-audit.js"; import { createHash } from "node:crypto"; import { promises as fs } from "node:fs"; import { dirname, join } from "node:path"; @@ -538,7 +538,7 @@ export class DeepScanWorkerRunner { throw withWorkerDiagnostics(validationError, result.diagnostics); } validationCompleted = accepted?.complete !== false; - return accepted === undefined ? {} : auditEvidence(accepted); + return accepted; }; // Reducers keep their aggregate contract; discovery uses the shared audit. const audit = input.kind === "discovery" diff --git a/plugins/codex-security/mcp-app/tests/test_audit_acceptance_contract.mjs b/plugins/codex-security/mcp-app/tests/test_audit_acceptance_contract.mjs index 8d1e108d1..5f634a70e 100644 --- a/plugins/codex-security/mcp-app/tests/test_audit_acceptance_contract.mjs +++ b/plugins/codex-security/mcp-app/tests/test_audit_acceptance_contract.mjs @@ -19,7 +19,7 @@ const bundle = await build({ const { createDeepScanArtifacts, recordCodexSecurityScanDraft, recordCodexSecurityWorkerScanDraft, validateDiscoveryArtifacts, - readDiscoveryAuditDraft, auditEvidence, runAcceptedAudit, + readDiscoveryAuditDraft, runAcceptedAudit, } = await import(`data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].contents).toString("base64")}`); const scanId = "811aef98-3709-4c2d-8b7a-742977521865"; @@ -72,9 +72,9 @@ for (const completeness of ["complete", "partial", "unknown"]) { assert.equal(JSON.parse(await readFile(path.join(scanDir, "scan-manifest.json"))).scan.complete, false); const controller = new AbortController(); const execute = async () => ({ threadId: "audit-conversation", usage: null }); - const accept = async () => auditEvidence(await readDiscoveryAuditDraft( + const accept = async () => await readDiscoveryAuditDraft( artifacts, path.join(workerRoot, "result.json"), scanId, - )); + ); const unfinished = await runAcceptedAudit({ signal: controller.signal, execute, accept }); assert.equal(unfinished.status, "checkpoint"); assert.equal(unfinished.checkpoint.complete, false); diff --git a/sdk/typescript/src/accepted-audit.ts b/sdk/typescript/src/accepted-audit.ts index ee526a955..f03cf3a3b 100644 --- a/sdk/typescript/src/accepted-audit.ts +++ b/sdk/typescript/src/accepted-audit.ts @@ -9,41 +9,28 @@ export interface ScanDraftInput { } /** Accepted evidence may still describe partial or unknown source coverage. */ -export interface AuditEvidence { +export type AuditOutcome = { + execution: Execution; checkpoint?: ScanDraftInput; - accepted?: ScanDraftInput; -} - -export type AuditOutcome = AuditEvidence & - ( - | { status: "accepted"; execution: Execution; accepted: ScanDraftInput } - | { status: "checkpoint"; execution: Execution } - ); +} & ( + | { status: "accepted"; accepted: ScanDraftInput } + | { status: "checkpoint" } +); /** One attempt; enclosing callers own retries and public completion. */ export async function runAcceptedAudit(input: { signal: AbortSignal; execute: () => Promise; - accept: (execution: Execution) => Promise; + accept: (execution: Execution) => Promise; }): Promise> { input.signal.throwIfAborted(); const execution = await input.execute(); input.signal.throwIfAborted(); - const evidence = await input.accept(execution); + const checkpoint = await input.accept(execution); input.signal.throwIfAborted(); - return evidence.accepted === undefined - ? { ...evidence, execution, status: "checkpoint" } - : { - ...evidence, - execution, - status: "accepted", - accepted: evidence.accepted, - }; -} - -/** Process completion alone does not accept an unfinished audit checkpoint. */ -export function auditEvidence(checkpoint: ScanDraftInput): AuditEvidence { + if (checkpoint === undefined) return { execution, status: "checkpoint" }; + // Process completion alone does not accept an unfinished audit checkpoint. return checkpoint.complete === false - ? { checkpoint } - : { checkpoint, accepted: checkpoint }; + ? { checkpoint, execution, status: "checkpoint" } + : { checkpoint, execution, status: "accepted", accepted: checkpoint }; } diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index b8b014925..958a60e4a 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -1,7 +1,6 @@ /// import { - auditEvidence, runAcceptedAudit, type ScanDraftInput, } from "./accepted-audit.js"; @@ -3736,7 +3735,7 @@ export async function runScanEvents( } return { ...turn, threadId, status }; }; - const accept = async () => { + const accept = async (): Promise => { // Matching, custom validation and the canonical seal remain with the caller. const [manifest, findings, coverage] = await Promise.all( ["scan-manifest.json", "findings.json", "coverage.json"].map( @@ -3753,13 +3752,12 @@ export async function runScanEvents( pathToFileURL(join(options.pluginRoot, "mcp/helpers.mjs")).href ) ).default; - const draft: ScanDraftInput = helper.parseCanonicalScanDraft({ + return helper.parseCanonicalScanDraft({ scanId: options.scanId ?? manifest.scan.id, manifest, findings, coverage, }); - return auditEvidence(draft); }; const audit = await runAcceptedAudit({ signal: options.signal, From 9593aad1c87569eb15c80bec434fb9a8b38ab7da Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 16 Sep 2026 06:54:55 +0000 Subject: [PATCH 06/12] Format shared audit imports and types --- sdk/typescript/src/accepted-audit.ts | 3 +-- sdk/typescript/src/api.ts | 5 +---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/sdk/typescript/src/accepted-audit.ts b/sdk/typescript/src/accepted-audit.ts index f03cf3a3b..fe3eccb74 100644 --- a/sdk/typescript/src/accepted-audit.ts +++ b/sdk/typescript/src/accepted-audit.ts @@ -13,8 +13,7 @@ export type AuditOutcome = { execution: Execution; checkpoint?: ScanDraftInput; } & ( - | { status: "accepted"; accepted: ScanDraftInput } - | { status: "checkpoint" } + { status: "accepted"; accepted: ScanDraftInput } | { status: "checkpoint" } ); /** One attempt; enclosing callers own retries and public completion. */ diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index a2026b1bf..bce2f2c04 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -1,9 +1,6 @@ /// -import { - runAcceptedAudit, - type ScanDraftInput, -} from "./accepted-audit.js"; +import { runAcceptedAudit, type ScanDraftInput } from "./accepted-audit.js"; import { pathToFileURL } from "node:url"; import { statSync } from "node:fs"; import { From 5f8a618e082d7b7f391deeb74dc0a00aefbeb0a8 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 16 Sep 2026 09:19:24 +0000 Subject: [PATCH 07/12] Preserve canonical admission with selected older plugins --- .../mcp-app/src/artifact-scan-draft.ts | 10 +- .../fixtures/package-plugin-compatibility.mjs | 175 ++++++++++++++++++ sdk/typescript/scripts/smoke-package.mjs | 14 ++ sdk/typescript/src/api.ts | 3 +- .../tests-ts/api-audit-admission.test.ts | 90 +++++---- 5 files changed, 255 insertions(+), 37 deletions(-) create mode 100644 sdk/typescript/scripts/fixtures/package-plugin-compatibility.mjs diff --git a/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts b/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts index d09f28e37..9006b6237 100644 --- a/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts +++ b/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts @@ -5,6 +5,7 @@ import type * as z from "zod/v4"; import commonSchema from "../../schemas/definitions/artifact-common.schema.json"; import scanDraftDocument from "../../schemas/tools/scan-draft.schema.json"; import scanManifestDocument from "../../schemas/scan-manifest.schema.json"; +import coverageDocument from "../../schemas/coverage.schema.json"; import type { ArtifactContext } from "./artifact-context.js"; import type { RunArtifactWorkbench } from "./artifact-context.js"; import { @@ -63,13 +64,20 @@ export const scanDraftInputSchema = loadArtifactZodSchema( "scanDraftInput", ) as z.ZodType; -// Sealed documents retain the existing public ID contract; live drafts require UUIDs. +// Canonical documents retain their public IDs; live drafts use UUIDs and slugs. const canonicalScanDraftInputSchema = loadArtifactZodSchema( [commonSchema, { ...scanDraftDocument, $defs: { ...scanDraftDocument.$defs, scanId: scanManifestDocument.properties.scan.properties.id, + surface: { + ...scanDraftDocument.$defs.surface, + properties: { + ...scanDraftDocument.$defs.surface.properties, + id: coverageDocument.properties.surfaces.items.properties.id, + }, + }, }, }] as SchemaDocument[], scanDraftDocument.$id, diff --git a/sdk/typescript/scripts/fixtures/package-plugin-compatibility.mjs b/sdk/typescript/scripts/fixtures/package-plugin-compatibility.mjs new file mode 100644 index 000000000..2c08f927c --- /dev/null +++ b/sdk/typescript/scripts/fixtures/package-plugin-compatibility.mjs @@ -0,0 +1,175 @@ +import assert from "node:assert/strict"; +import { cp, mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +const [installedRoot, consumer, selectedPlugin] = process.argv.slice(2); +const { CodexSecurity } = await import( + pathToFileURL(join(installedRoot, "dist", "index.js")).href +); +const { runWorkbench } = await import( + pathToFileURL(join(installedRoot, "dist", "runtime.js")).href +); +const repository = join(consumer, "compatibility-repository"); +const home = join(consumer, "compatibility-home"); +await mkdir(repository, { mode: 0o700 }); +await mkdir(home, { mode: 0o700 }); +await writeFile(join(repository, "example.py"), "print('synthetic fixture')\n"); +const environment = { + ...Object.fromEntries( + [ + "PATH", + "Path", + "SystemRoot", + "WINDIR", + "ComSpec", + "PATHEXT", + "TMP", + "TEMP", + "TMPDIR", + ] + .filter((key) => process.env[key] !== undefined) + .map((key) => [key, process.env[key]]), + ), + HOME: home, + USERPROFILE: home, + CODEX_HOME: home, + CODEX_SECURITY_STATE_DIR: join(consumer, "compatibility-state"), + OPENAI_API_KEY: "synthetic-compatibility-key", +}; +const postScanPrompt = "Summarize the completed synthetic scan."; +const turns = []; +let scanEnvironment; +// Only model execution is replaced. Plugin selection, bootstrap, admission, +// finalization, report generation and workbench completion use the installed SDK. +const client = new CodexSecurity( + { pythonPath: process.env.PYTHON, pluginPath: selectedPlugin }, + { + environment, + createCodex({ env }) { + scanEnvironment = env; + return { + startThread() { + return { + id: "compatibility-thread", + async runStreamed(prompt) { + turns.push(prompt); + return { + events: (async function* () { + if (turns.length === 1) { + const plugin = env.CODEX_SECURITY_PLUGIN_ROOT; + const scanDir = env.CODEX_SECURITY_SCAN_DIR; + await cp( + join(plugin, "examples", "completed-scan"), + scanDir, + { recursive: true }, + ); + const manifestPath = join(scanDir, "scan-manifest.json"); + const manifest = JSON.parse( + await readFile(manifestPath, "utf8"), + ); + const metadata = JSON.parse( + await readFile( + join(plugin, ".codex-plugin", "plugin.json"), + "utf8", + ), + ); + manifest.scan.id = env.CODEX_SECURITY_SCAN_ID; + manifest.scan.producer.version = metadata.version; + delete manifest.scan.sealedAt; + delete manifest.scan.artifacts; + manifest.scan.target = { + kind: env.CODEX_SECURITY_TARGET_KIND, + targetId: env.CODEX_SECURITY_TARGET_ID, + displayName: env.CODEX_SECURITY_TARGET_DISPLAY_NAME, + snapshotDigest: env.CODEX_SECURITY_TARGET_SNAPSHOT_DIGEST, + }; + for (const name of ["findings.json", "coverage.json"]) { + const file = join(scanDir, name); + const document = JSON.parse(await readFile(file, "utf8")); + document.scanId = manifest.scan.id; + for (const finding of document.findings ?? []) { + delete finding.findingId; + delete finding.occurrenceId; + delete finding.fingerprints; + } + if (name === "coverage.json") { + document.surfaces = ["HTTP API", "ArchiveSurface"].map( + (id) => ({ + ...document.surfaces[0], + id, + }), + ); + } + await writeFile(file, JSON.stringify(document)); + } + await writeFile(manifestPath, JSON.stringify(manifest)); + } else { + assert.equal(prompt, postScanPrompt); + const manifest = JSON.parse( + await readFile( + join(env.CODEX_SECURITY_SCAN_DIR, "scan-manifest.json"), + "utf8", + ), + ); + assert.equal(manifest.scan.status, "completed"); + assert.ok(manifest.scan.sealedAt); + assert.ok( + ( + await stat( + join(env.CODEX_SECURITY_SCAN_DIR, "report.md"), + ) + ).isFile(), + ); + } + yield { + type: "thread.started", + thread_id: "compatibility-thread", + }; + yield { type: "turn.completed", usage: null }; + })(), + }; + }, + }; + }, + }; + }, + }, +); +try { + const result = await client.run(repository, { + outputDir: join(consumer, "compatibility-output"), + postScanPrompt, + }); + assert.equal(result.manifest.scan.status, "completed"); + assert.ok(result.manifest.scan.sealedAt); + assert.deepEqual( + result.coverage.surfaces.map((surface) => surface.id), + ["HTTP API", "ArchiveSurface"], + ); + assert.equal(turns.length, 2); + assert.equal(result.findings.findings.length, 1); + const saved = await runWorkbench( + { + python: scanEnvironment.PYTHON, + pluginRoot: scanEnvironment.CODEX_SECURITY_PLUGIN_ROOT, + environment: scanEnvironment, + }, + ["get-scan", "--scan-id", result.manifest.scan.id], + ); + assert.equal(saved.scan.progress.status, "complete"); + assert.equal(process.exitCode ?? 0, 0); + console.log( + JSON.stringify({ + selectedPlugin, + installedPlugin: scanEnvironment.CODEX_SECURITY_PLUGIN_ROOT, + scanId: result.manifest.scan.id, + scanDir: result.scanDir, + turns: turns.length, + status: saved.scan.progress.status, + coverageIds: result.coverage.surfaces.map((surface) => surface.id), + }), + ); +} finally { + await client.close(); +} diff --git a/sdk/typescript/scripts/smoke-package.mjs b/sdk/typescript/scripts/smoke-package.mjs index 39f834a5d..49a233285 100644 --- a/sdk/typescript/scripts/smoke-package.mjs +++ b/sdk/typescript/scripts/smoke-package.mjs @@ -806,6 +806,20 @@ try { ], { cwd: consumer }, ); + run( + process.execPath, + [ + join( + packageRoot, + "scripts", + "fixtures", + "package-plugin-compatibility.mjs", + ), + installedRoot, + consumer, + ], + { cwd: consumer }, + ); await smokeNestedDeepScanWorker(installedRoot, consumer); run( diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index bce2f2c04..c0b8673b4 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -3747,9 +3747,10 @@ export async function runScanEvents( ), ), ); + // Admission uses the SDK parser even when executing an older plugin. const helper = ( await import( - pathToFileURL(join(options.pluginRoot, "mcp/helpers.mjs")).href + pathToFileURL(join(await bundledPluginRoot(), "mcp/helpers.mjs")).href ) ).default; return helper.parseCanonicalScanDraft({ diff --git a/sdk/typescript/tests-ts/api-audit-admission.test.ts b/sdk/typescript/tests-ts/api-audit-admission.test.ts index 0bfc8c269..c17b357a2 100644 --- a/sdk/typescript/tests-ts/api-audit-admission.test.ts +++ b/sdk/typescript/tests-ts/api-audit-admission.test.ts @@ -36,6 +36,8 @@ await writeFile(bundlePath, bundle.outputFiles[0]!.contents); const { createDeepScanArtifacts, recordCodexSecurityScanDraft, + parseCanonicalScanDraft, + parseScanDraft, readDiscoveryAuditDraft, DeepScanWorkerRunner, } = await import(pathToFileURL(bundlePath).href); @@ -198,7 +200,6 @@ for (const scenario of cases) { writeFile(join(standardRoot, "coverage.json"), JSON.stringify(coverage)), ]); const standard = await observeStandardAdmission( - root, repository, standardRoot, scanId, @@ -257,7 +258,6 @@ for (const scenario of cases) { if (scenario.accepted) { expect(standard.error).toBe(standard.finalization); expect(standard.finalizations).toBe(1); - expect(standard.drafts).toHaveLength(1); expect(deepResult.status).toBe("succeeded"); expect(acceptedPaths).toEqual([deepResult.worker.resultPath]); const deepDraft: ScanDraftInput = await readDiscoveryAuditDraft( @@ -265,10 +265,18 @@ for (const scenario of cases) { deepResult.worker.resultPath, scanId, ); - expect(standard.drafts[0]!.findings).toEqual(deepDraft.findings); - expect(standard.drafts[0]!.coverage).toEqual(deepDraft.coverage); - expect(standard.drafts[0]!.scope).toEqual(deepDraft.scope); - expect(standard.drafts[0]!.threatModel).toEqual(deepDraft.threatModel); + const canonicalDraft = parseCanonicalScanDraft({ + scanId, + manifest: JSON.parse( + await readFile(join(standardRoot, "scan-manifest.json"), "utf8"), + ), + findings, + coverage, + }); + expect(deepDraft.findings).toEqual(canonicalDraft.findings); + expect(deepDraft.coverage).toEqual(canonicalDraft.coverage); + expect(deepDraft.scope).toEqual(canonicalDraft.scope); + expect(deepDraft.threatModel).toEqual(canonicalDraft.threatModel); if (scenario.mutation === "legacy-details") { expect(deepDraft.findings[0]!["validation"]).toEqual({ limitations: ["Legacy persisted limitation."], @@ -281,7 +289,6 @@ for (const scenario of cases) { expect(deepResult.status).toBe("failed"); expect(acceptedPaths).toHaveLength(0); } - expect(standard.calls).toBe(1); const manifest = JSON.parse( await readFile(join(standardRoot, "scan-manifest.json"), "utf8"), ); @@ -298,41 +305,56 @@ test("Standard admission preserves existing canonical scan IDs", async () => { const manifest = JSON.parse( await readFile(join(scanDir, "scan-manifest.json"), "utf8"), ); - const standard = await observeStandardAdmission(root, repository, scanDir); + const standard = await observeStandardAdmission(repository, scanDir); expect(manifest.scan.id).toBe("scan_example_001"); expect(standard.error).toBe(standard.finalization); expect(standard.finalizations).toBe(1); - expect(standard.drafts).toHaveLength(1); - expect(standard.calls).toBe(1); - expect(standard.drafts[0]!.scanId).toBe(manifest.scan.id); }); +test.each(["HTTP API", "ArchiveSurface", "", 17])( + "canonical coverage ID %j follows the canonical contract", + async (id) => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + await mkdir(repository); + const scanDir = await copyCompletedScan(root); + const [manifest, findings, coverage] = await Promise.all( + ["scan-manifest.json", "findings.json", "coverage.json"].map( + async (name) => JSON.parse(await readFile(join(scanDir, name), "utf8")), + ), + ); + coverage.surfaces[0].id = id; + await writeFile(join(scanDir, "coverage.json"), JSON.stringify(coverage)); + const canonical = { + scanId: manifest.scan.id, + manifest, + findings, + coverage, + }; + const standard = await observeStandardAdmission(repository, scanDir); + if (typeof id === "string" && id.length > 0) { + const draft = parseCanonicalScanDraft(canonical); + expect(draft.coverage.surfaces[0].id).toBe(id); + expect(standard.error).toBe(standard.finalization); + expect(standard.finalizations).toBe(1); + expect(() => parseScanDraft({ ...draft, scanId })).toThrow(); + const live = structuredClone(draft); + for (const surface of live.coverage.surfaces) surface.id = "http-api"; + expect(() => parseScanDraft({ ...live, scanId })).not.toThrow(); + } else { + expect(() => parseCanonicalScanDraft(canonical)).toThrow(); + expect(standard.error).toBeInstanceOf(Error); + expect(standard.error).not.toBe(standard.finalization); + expect(standard.finalizations).toBe(0); + } + }, +); + async function observeStandardAdmission( - root: string, repository: string, scanDir: string, scanId?: string, ) { - const pluginRoot = join(root, "observed-plugin"); - const helperPath = join(pluginRoot, "mcp", "helpers.mjs"); - await mkdir(join(pluginRoot, "mcp"), { recursive: true }); - // The bundled exports are immutable getters. Observe the real parser through - // a local delegator instead of mocking its module or copying its semantics. - await writeFile( - helperPath, - `import helpers from ${JSON.stringify(pathToFileURL(join(PLUGIN_ROOT, "mcp", "helpers.mjs")).href)}; -export const drafts = []; -export let calls = 0; -export default { ...helpers, parseCanonicalScanDraft(input) { - calls++; - const draft = helpers.parseCanonicalScanDraft(input); - drafts.push(draft); - return draft; -} };`, - ); - const observed: { drafts: ScanDraftInput[]; calls: number } = await import( - pathToFileURL(helperPath).href - ); const finalization = new Error("The enclosing finalizer owns the next step."); let finalizations = 0; const error = await runScanEvents({ @@ -346,7 +368,7 @@ export default { ...helpers, parseCanonicalScanDraft(input) { events: completedEvents("standard-thread"), signal: new AbortController().signal, scanDir, - pluginRoot, + pluginRoot: PLUGIN_ROOT, expectation: { repository, repositoryRevision: null, @@ -363,8 +385,6 @@ export default { ...helpers, parseCanonicalScanDraft(input) { error, finalization, finalizations, - drafts: observed.drafts, - calls: observed.calls, }; } From 82cf99c38f9c36f3ecc3bc9de6321e1b6a505f3f Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 16 Sep 2026 11:03:38 +0000 Subject: [PATCH 08/12] Use canonical field rules for persisted audit admission --- .../mcp-app/src/artifact-scan-draft.ts | 34 +++++-- .../fixtures/package-plugin-compatibility.mjs | 18 ++++ sdk/typescript/src/contract-path.ts | 35 +++++++ sdk/typescript/src/contract.ts | 36 +------ .../tests-ts/api-audit-admission.test.ts | 98 ++++++++++++++++++- 5 files changed, 177 insertions(+), 44 deletions(-) create mode 100644 sdk/typescript/src/contract-path.ts diff --git a/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts b/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts index 9006b6237..87bd554d3 100644 --- a/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts +++ b/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts @@ -6,6 +6,8 @@ import commonSchema from "../../schemas/definitions/artifact-common.schema.json" import scanDraftDocument from "../../schemas/tools/scan-draft.schema.json"; import scanManifestDocument from "../../schemas/scan-manifest.schema.json"; import coverageDocument from "../../schemas/coverage.schema.json"; +import findingsDocument from "../../schemas/findings.schema.json"; +import { safeRelativePath } from "../../../../sdk/typescript/src/contract-path.js"; import type { ArtifactContext } from "./artifact-context.js"; import type { RunArtifactWorkbench } from "./artifact-context.js"; import { @@ -64,19 +66,25 @@ export const scanDraftInputSchema = loadArtifactZodSchema( "scanDraftInput", ) as z.ZodType; -// Canonical documents retain their public IDs; live drafts use UUIDs and slugs. +// Keep draft completion/host-field handling, using canonical persisted field rules. const canonicalScanDraftInputSchema = loadArtifactZodSchema( [commonSchema, { ...scanDraftDocument, $defs: { ...scanDraftDocument.$defs, scanId: scanManifestDocument.properties.scan.properties.id, - surface: { - ...scanDraftDocument.$defs.surface, - properties: { - ...scanDraftDocument.$defs.surface.properties, - id: coverageDocument.properties.surfaces.items.properties.id, - }, + scope: { + ...scanDraftDocument.$defs.scope, + properties: scanManifestDocument.properties.scan.properties.scope.properties, + }, + threatModel: scanManifestDocument.properties.scan.properties.threatModel, + finding: { + ...scanDraftDocument.$defs.finding, + properties: findingsDocument.properties.findings.items.properties, + }, + coverage: { + ...scanDraftDocument.$defs.coverage, + properties: coverageDocument.properties, }, }, }] as SchemaDocument[], @@ -965,7 +973,7 @@ export async function getCodexSecurityCompletedScan( return { scanId: parsed.scanId, manifest, findings, coverage }; } -/** Project canonical documents through the same semantic parser as worker drafts. */ +/** Admit canonical fields while retaining shared audit semantics and path safety. */ export function parseCanonicalScanDraft(input: { scanId?: string; manifest: JsonObject; @@ -978,7 +986,7 @@ export function parseCanonicalScanDraft(input: { throw new Error("scan draft: canonical documents belong to a different scan."); } } - return parsePersistedCheckpoint({ + const parsed = parsePersistedCheckpoint({ scanId: input.scanId, ...(scan.complete === undefined ? {} : { complete: scan.complete }), ...(scan.scope === undefined ? {} : { scope: scan.scope }), @@ -986,6 +994,14 @@ export function parseCanonicalScanDraft(input: { findings: input.findings.findings, coverage: input.coverage, }, canonicalScanDraftInputSchema); + for (const [index, finding] of parsed.findings.entries()) { + for (const field of ["locations", "codeEvidence"] as const) { + for (const [locationIndex, location] of ((finding[field] as JsonObject[] | undefined) ?? []).entries()) { + safeRelativePath(location.path as string, `findings[${index}].${field}[${locationIndex}].path`); + } + } + } + return parsed; } export function parseScanDraft(input: ScanDraftInput): ScanDraftInput { diff --git a/sdk/typescript/scripts/fixtures/package-plugin-compatibility.mjs b/sdk/typescript/scripts/fixtures/package-plugin-compatibility.mjs index 2c08f927c..751b6cfba 100644 --- a/sdk/typescript/scripts/fixtures/package-plugin-compatibility.mjs +++ b/sdk/typescript/scripts/fixtures/package-plugin-compatibility.mjs @@ -78,6 +78,11 @@ const client = new CodexSecurity( manifest.scan.producer.version = metadata.version; delete manifest.scan.sealedAt; delete manifest.scan.artifacts; + manifest.scan.scope.context = " "; + manifest.scan.threatModel = { + summary: "Archive input", + assumptions: [" "], + }; manifest.scan.target = { kind: env.CODEX_SECURITY_TARGET_KIND, targetId: env.CODEX_SECURITY_TARGET_ID, @@ -92,12 +97,16 @@ const client = new CodexSecurity( delete finding.findingId; delete finding.occurrenceId; delete finding.fingerprints; + finding.locations[0].path = "src/./extract.py"; + finding.locations[0].role = " "; } if (name === "coverage.json") { document.surfaces = ["HTTP API", "ArchiveSurface"].map( (id) => ({ ...document.surfaces[0], id, + notes: " ", + riskArea: " ", }), ); } @@ -149,6 +158,15 @@ try { ); assert.equal(turns.length, 2); assert.equal(result.findings.findings.length, 1); + assert.equal( + result.findings.findings[0].locations[0].path, + "src/./extract.py", + ); + assert.equal(result.findings.findings[0].locations[0].role, " "); + assert.equal(result.coverage.surfaces[0].notes, " "); + assert.equal(result.coverage.surfaces[0].riskArea, " "); + assert.equal(result.manifest.scan.scope.context, " "); + assert.deepEqual(result.manifest.scan.threatModel.assumptions, [" "]); const saved = await runWorkbench( { python: scanEnvironment.PYTHON, diff --git a/sdk/typescript/src/contract-path.ts b/sdk/typescript/src/contract-path.ts new file mode 100644 index 000000000..9e5b2c576 --- /dev/null +++ b/sdk/typescript/src/contract-path.ts @@ -0,0 +1,35 @@ +import { isAbsolute, posix } from "node:path"; +import { ContractValidationError } from "./errors.js"; + +export function safeRelativePath(value: string, context: string): string { + const parts = value.split("/"); + if ( + value.trim().length === 0 || + !isWellFormedUnicode(value) || + value === "." || + value.startsWith("/") || + /^[A-Za-z]:/.test(value) || + parts.includes("..") || + value.includes("\\") || + /[\u0000-\u001f]/u.test(value) + ) { + throw new ContractValidationError( + `${context}: expected a safe scan-relative POSIX path.`, + ); + } + const normalized = posix.normalize(value).replace(/\/+$/, ""); + if ( + normalized === "." || + normalized.startsWith("../") || + isAbsolute(normalized) + ) { + throw new ContractValidationError( + `${context}: expected a safe scan-relative POSIX path.`, + ); + } + return normalized; +} + +export function isWellFormedUnicode(value: string): boolean { + return Buffer.from(value, "utf8").toString("utf8") === value; +} diff --git a/sdk/typescript/src/contract.ts b/sdk/typescript/src/contract.ts index f88d88914..6c6f58a8e 100644 --- a/sdk/typescript/src/contract.ts +++ b/sdk/typescript/src/contract.ts @@ -7,9 +7,10 @@ import { realpath, type FileHandle, } from "node:fs/promises"; -import { isAbsolute, join, posix, relative, resolve, sep } from "node:path"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; import Ajv2020, { type ErrorObject } from "ajv/dist/2020.js"; import { ContractValidationError } from "./errors.js"; +import { isWellFormedUnicode, safeRelativePath } from "./contract-path.js"; import type { CoverageDocument, FindingsDocument, @@ -874,35 +875,6 @@ async function verifyScanRoot( } } -function safeRelativePath(value: string, context: string): string { - const parts = value.split("/"); - if ( - value.trim().length === 0 || - !isWellFormedUnicode(value) || - value === "." || - value.startsWith("/") || - /^[A-Za-z]:/.test(value) || - parts.includes("..") || - value.includes("\\") || - /[\u0000-\u001f]/u.test(value) - ) { - throw new ContractValidationError( - `${context}: expected a safe scan-relative POSIX path.`, - ); - } - const normalized = posix.normalize(value).replace(/\/+$/, ""); - if ( - normalized === "." || - normalized.startsWith("../") || - isAbsolute(normalized) - ) { - throw new ContractValidationError( - `${context}: expected a safe scan-relative POSIX path.`, - ); - } - return normalized; -} - function portableRelativePath(value: string, context: string): string { const normalized = safeRelativePath(value, context); if (value.split("/").some(isWindowsUnsafePathComponent)) { @@ -1037,10 +1009,6 @@ function validateParsedJson(value: unknown, context: string): void { } } -function isWellFormedUnicode(value: string): boolean { - return Buffer.from(value, "utf8").toString("utf8") === value; -} - function createValidator(): Ajv2020 { // The plugin schemas are the immutable v0 contract. They are valid Draft // 2020-12 but intentionally omit redundant local `type` keywords that Ajv's diff --git a/sdk/typescript/tests-ts/api-audit-admission.test.ts b/sdk/typescript/tests-ts/api-audit-admission.test.ts index c17b357a2..3d21130c3 100644 --- a/sdk/typescript/tests-ts/api-audit-admission.test.ts +++ b/sdk/typescript/tests-ts/api-audit-admission.test.ts @@ -146,7 +146,14 @@ for (const scenario of cases) { ], coverage: { completeness: scenario.coverage, - surfaces: [{ label: "Archive extraction", disposition: "reported" }], + surfaces: [ + { + id: "archive-extraction", + label: "Archive extraction", + disposition: "reported", + receiptRefs: [], + }, + ], explicitExclusions: [], deferred: scenario.coverage === "complete" @@ -350,6 +357,95 @@ test.each(["HTTP API", "ArchiveSurface", "", 17])( }, ); +test.each([ + { path: "src/./extract.py", notes: " ", accepted: true }, + { path: "src//extract.py", notes: " ", accepted: true }, + { path: "src/extract.py/", notes: " ", accepted: true }, + { path: "../extract.py", notes: "Checked", accepted: false }, + { path: "/src/extract.py", notes: "Checked", accepted: false }, + { path: "C:/extract.py", notes: "Checked", accepted: false }, + { path: "src\\extract.py", notes: "Checked", accepted: false }, + { path: "src/\u0000extract.py", notes: "Checked", accepted: false }, + { path: "src/\ud800extract.py", notes: "Checked", accepted: false }, + { path: ".", notes: "Checked", accepted: false }, + { path: "src/extract.py", notes: "", accepted: false }, + { path: "src/extract.py", notes: 17, accepted: false }, +])( + "canonical fields retain their rules: %j", + async ({ path, notes, accepted }) => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + await mkdir(repository); + const scanDir = await copyCompletedScan(root); + const [manifest, findings, coverage] = await Promise.all( + ["scan-manifest.json", "findings.json", "coverage.json"].map( + async (name) => JSON.parse(await readFile(join(scanDir, name), "utf8")), + ), + ); + manifest.scan.scope.context = " "; + manifest.scan.threatModel = { + summary: "Archive input", + assumptions: [" "], + }; + const finding = findings.findings[0]; + finding.locations[0].path = path; + finding.locations[0].role = " "; + finding.severity.vector = " "; + finding.codeEvidence = [ + { + id: "archive-write", + label: "Filesystem write", + path, + startLine: 41, + code: "write(entry)", + explanation: "Archive entry reaches a write.", + }, + ]; + coverage.surfaces[0].notes = notes; + coverage.surfaces[0].riskArea = " "; + await Promise.all([ + writeFile(join(scanDir, "scan-manifest.json"), JSON.stringify(manifest)), + writeFile(join(scanDir, "findings.json"), JSON.stringify(findings)), + writeFile(join(scanDir, "coverage.json"), JSON.stringify(coverage)), + ]); + const canonical = { + scanId: manifest.scan.id, + manifest, + findings, + coverage, + }; + const standard = await observeStandardAdmission(repository, scanDir); + if (accepted) { + const draft = parseCanonicalScanDraft(canonical); + expect(draft.findings[0].locations[0].path).toBe(path); + expect(draft.coverage.surfaces[0].notes).toBe(notes); + expect(draft.scope.context).toBe(" "); + expect(draft.threatModel.assumptions).toEqual([" "]); + expect(standard.error).toBe(standard.finalization); + expect(standard.finalizations).toBe(1); + expect(() => parseScanDraft({ ...draft, scanId })).toThrow(); + const live = structuredClone(draft); + delete live.scope.context; + delete live.threatModel; + delete live.findings[0].severity.vector; + live.findings[0].locations[0] = { path: "src/extract.py", startLine: 41 }; + live.findings[0].codeEvidence[0].path = "src/extract.py"; + live.coverage.surfaces[0].notes = "Checked"; + delete live.coverage.surfaces[0].riskArea; + expect(() => parseScanDraft({ ...live, scanId })).not.toThrow(); + const unsafeEvidence = structuredClone(canonical); + unsafeEvidence.findings.findings[0].codeEvidence[0].path = + "../outside.py"; + expect(() => parseCanonicalScanDraft(unsafeEvidence)).toThrow(); + } else { + expect(() => parseCanonicalScanDraft(canonical)).toThrow(); + expect(standard.error).toBeInstanceOf(Error); + expect(standard.error).not.toBe(standard.finalization); + expect(standard.finalizations).toBe(0); + } + }, +); + async function observeStandardAdmission( repository: string, scanDir: string, From b71cb8b3ba59929f1c4e916fb295805589593696 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 16 Sep 2026 11:08:55 +0000 Subject: [PATCH 09/12] Include the shared contract path helper in package checks --- sdk/typescript/scripts/check-package.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/typescript/scripts/check-package.mjs b/sdk/typescript/scripts/check-package.mjs index fec3213c9..348fbcec6 100644 --- a/sdk/typescript/scripts/check-package.mjs +++ b/sdk/typescript/scripts/check-package.mjs @@ -183,6 +183,7 @@ const distFiles = new Set( "config", "config-path", "contract", + "contract-path", "cost", "cost-model", "custom-validation", From f77d9bf1bf03b52e3055b40ca6aeb2c5f629126b Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 16 Sep 2026 11:23:58 +0000 Subject: [PATCH 10/12] Keep unsealed question inputs compatible with finalization --- .../mcp-app/src/artifact-scan-draft.ts | 14 +++++++++++++- .../fixtures/package-plugin-compatibility.mjs | 6 ++++++ .../tests-ts/api-audit-admission.test.ts | 14 +++++++++++++- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts b/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts index 87bd554d3..4c100422e 100644 --- a/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts +++ b/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts @@ -84,7 +84,19 @@ const canonicalScanDraftInputSchema = loadArtifactZodSchema( }, coverage: { ...scanDraftDocument.$defs.coverage, - properties: coverageDocument.properties, + properties: { + ...coverageDocument.properties, + // String questions are normalized by the finalizer after admission. + openQuestions: { + ...coverageDocument.properties.openQuestions, + items: { + anyOf: [ + scanDraftDocument.$defs.coverage.properties.openQuestions.items.anyOf[0], + coverageDocument.properties.openQuestions.items, + ], + }, + }, + }, }, }, }] as SchemaDocument[], diff --git a/sdk/typescript/scripts/fixtures/package-plugin-compatibility.mjs b/sdk/typescript/scripts/fixtures/package-plugin-compatibility.mjs index 751b6cfba..c7dd5faa2 100644 --- a/sdk/typescript/scripts/fixtures/package-plugin-compatibility.mjs +++ b/sdk/typescript/scripts/fixtures/package-plugin-compatibility.mjs @@ -101,6 +101,9 @@ const client = new CodexSecurity( finding.locations[0].role = " "; } if (name === "coverage.json") { + document.openQuestions = [ + "What deployment controls apply?", + ]; document.surfaces = ["HTTP API", "ArchiveSurface"].map( (id) => ({ ...document.surfaces[0], @@ -165,6 +168,9 @@ try { assert.equal(result.findings.findings[0].locations[0].role, " "); assert.equal(result.coverage.surfaces[0].notes, " "); assert.equal(result.coverage.surfaces[0].riskArea, " "); + assert.deepEqual(result.coverage.openQuestions, [ + { question: "What deployment controls apply?" }, + ]); assert.equal(result.manifest.scan.scope.context, " "); assert.deepEqual(result.manifest.scan.threatModel.assumptions, [" "]); const saved = await runWorkbench( diff --git a/sdk/typescript/tests-ts/api-audit-admission.test.ts b/sdk/typescript/tests-ts/api-audit-admission.test.ts index 3d21130c3..10ad528c7 100644 --- a/sdk/typescript/tests-ts/api-audit-admission.test.ts +++ b/sdk/typescript/tests-ts/api-audit-admission.test.ts @@ -370,9 +370,15 @@ test.each([ { path: ".", notes: "Checked", accepted: false }, { path: "src/extract.py", notes: "", accepted: false }, { path: "src/extract.py", notes: 17, accepted: false }, + { + path: "src/extract.py", + notes: "Checked", + questions: [17], + accepted: false, + }, ])( "canonical fields retain their rules: %j", - async ({ path, notes, accepted }) => { + async ({ path, notes, questions, accepted }) => { const root = await temporaryDirectory(); const repository = join(root, "repository"); await mkdir(repository); @@ -403,6 +409,10 @@ test.each([ ]; coverage.surfaces[0].notes = notes; coverage.surfaces[0].riskArea = " "; + coverage.openQuestions = questions ?? [ + "What deployment controls apply?", + { question: "Which controls apply?", followUpPrompt: " " }, + ]; await Promise.all([ writeFile(join(scanDir, "scan-manifest.json"), JSON.stringify(manifest)), writeFile(join(scanDir, "findings.json"), JSON.stringify(findings)), @@ -419,6 +429,7 @@ test.each([ const draft = parseCanonicalScanDraft(canonical); expect(draft.findings[0].locations[0].path).toBe(path); expect(draft.coverage.surfaces[0].notes).toBe(notes); + expect(draft.coverage.openQuestions).toEqual(coverage.openQuestions); expect(draft.scope.context).toBe(" "); expect(draft.threatModel.assumptions).toEqual([" "]); expect(standard.error).toBe(standard.finalization); @@ -432,6 +443,7 @@ test.each([ live.findings[0].codeEvidence[0].path = "src/extract.py"; live.coverage.surfaces[0].notes = "Checked"; delete live.coverage.surfaces[0].riskArea; + live.coverage.openQuestions[1].followUpPrompt = "Describe controls."; expect(() => parseScanDraft({ ...live, scanId })).not.toThrow(); const unsafeEvidence = structuredClone(canonical); unsafeEvidence.findings.findings[0].codeEvidence[0].path = From 36fee2f57421e5b6d9be07089ffe959e4bded73b Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 16 Sep 2026 11:28:49 +0000 Subject: [PATCH 11/12] Check question normalization without requiring deduplication --- .../scripts/fixtures/package-plugin-compatibility.mjs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/sdk/typescript/scripts/fixtures/package-plugin-compatibility.mjs b/sdk/typescript/scripts/fixtures/package-plugin-compatibility.mjs index c7dd5faa2..4bcabfefc 100644 --- a/sdk/typescript/scripts/fixtures/package-plugin-compatibility.mjs +++ b/sdk/typescript/scripts/fixtures/package-plugin-compatibility.mjs @@ -168,9 +168,12 @@ try { assert.equal(result.findings.findings[0].locations[0].role, " "); assert.equal(result.coverage.surfaces[0].notes, " "); assert.equal(result.coverage.surfaces[0].riskArea, " "); - assert.deepEqual(result.coverage.openQuestions, [ - { question: "What deployment controls apply?" }, - ]); + assert.ok( + result.coverage.openQuestions.length > 0 && + result.coverage.openQuestions.every( + (entry) => entry.question === "What deployment controls apply?", + ), + ); assert.equal(result.manifest.scan.scope.context, " "); assert.deepEqual(result.manifest.scan.threatModel.assumptions, [" "]); const saved = await runWorkbench( From d53829a8d41f4c8f4a82ac649f35148ef21eb8ef Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 16 Sep 2026 11:48:42 +0000 Subject: [PATCH 12/12] Preserve pre-finalization coverage metadata inputs --- .../mcp-app/src/artifact-scan-draft.ts | 14 ++++++++++++++ .../fixtures/package-plugin-compatibility.mjs | 13 +++++++++++++ .../tests-ts/api-audit-admission.test.ts | 3 --- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts b/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts index 4c100422e..7e21c94c8 100644 --- a/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts +++ b/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts @@ -86,6 +86,20 @@ const canonicalScanDraftInputSchema = loadArtifactZodSchema( ...scanDraftDocument.$defs.coverage, properties: { ...coverageDocument.properties, + surfaces: { + ...coverageDocument.properties.surfaces, + items: { + ...coverageDocument.properties.surfaces.items, + required: scanDraftDocument.$defs.surface.required, + }, + }, + deferred: { + ...coverageDocument.properties.deferred, + items: { + ...coverageDocument.properties.deferred.items, + required: scanDraftDocument.$defs.coverage.properties.deferred.items.required, + }, + }, // String questions are normalized by the finalizer after admission. openQuestions: { ...coverageDocument.properties.openQuestions, diff --git a/sdk/typescript/scripts/fixtures/package-plugin-compatibility.mjs b/sdk/typescript/scripts/fixtures/package-plugin-compatibility.mjs index 4bcabfefc..c2c21734b 100644 --- a/sdk/typescript/scripts/fixtures/package-plugin-compatibility.mjs +++ b/sdk/typescript/scripts/fixtures/package-plugin-compatibility.mjs @@ -112,6 +112,15 @@ const client = new CodexSecurity( riskArea: " ", }), ); + delete document.surfaces[1].receiptRefs; + document.surfaces.push({ + label: "Unidentified surface", + disposition: "reported", + }); + document.completeness = "partial"; + document.deferred = [ + { reason: "Deployment review remains." }, + ]; } await writeFile(file, JSON.stringify(document)); } @@ -168,6 +177,10 @@ try { assert.equal(result.findings.findings[0].locations[0].role, " "); assert.equal(result.coverage.surfaces[0].notes, " "); assert.equal(result.coverage.surfaces[0].riskArea, " "); + assert.equal(result.coverage.completeness, "partial"); + assert.equal(result.coverage.surfaces[1].disposition, "needs_follow_up"); + assert.deepEqual(result.coverage.surfaces[1].receiptRefs, []); + assert.deepEqual(result.coverage.deferred, []); assert.ok( result.coverage.openQuestions.length > 0 && result.coverage.openQuestions.every( diff --git a/sdk/typescript/tests-ts/api-audit-admission.test.ts b/sdk/typescript/tests-ts/api-audit-admission.test.ts index 10ad528c7..4fe0ea5a8 100644 --- a/sdk/typescript/tests-ts/api-audit-admission.test.ts +++ b/sdk/typescript/tests-ts/api-audit-admission.test.ts @@ -148,10 +148,8 @@ for (const scenario of cases) { completeness: scenario.coverage, surfaces: [ { - id: "archive-extraction", label: "Archive extraction", disposition: "reported", - receiptRefs: [], }, ], explicitExclusions: [], @@ -160,7 +158,6 @@ for (const scenario of cases) { ? [] : [ { - id: "deployment", reason: "Deployment controls remain unverified.", }, ],