diff --git a/plugins/codex-security/mcp-app/helpers-main.ts b/plugins/codex-security/mcp-app/helpers-main.ts index 18c8611be..4110c9b60 100644 --- a/plugins/codex-security/mcp-app/helpers-main.ts +++ b/plugins/codex-security/mcp-app/helpers-main.ts @@ -1,34 +1,47 @@ +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"; 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; +const invokedPath = process.argv[1]; +if ( + invokedPath && existsSync(invokedPath) + && realpathSync(invokedPath) === realpathSync(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..7e21c94c8 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,10 @@ 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 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 { @@ -17,17 +21,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 +66,58 @@ export const scanDraftInputSchema = loadArtifactZodSchema( "scanDraftInput", ) as z.ZodType; +// 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, + 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, + 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, + items: { + anyOf: [ + scanDraftDocument.$defs.coverage.properties.openQuestions.items.anyOf[0], + coverageDocument.properties.openQuestions.items, + ], + }, + }, + }, + }, + }, + }] as SchemaDocument[], + scanDraftDocument.$id, + "scanDraftInput", +) as z.ZodType; + export const completedScanInputSchema = loadArtifactZodSchema( schemaDocuments, scanDraftDocument.$id, @@ -548,32 +597,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 +999,43 @@ export async function getCodexSecurityCompletedScan( return { scanId: parsed.scanId, manifest, findings, coverage }; } +/** Admit canonical fields while retaining shared audit semantics and path safety. */ +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."); + } + } + const parsed = 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); + 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 { - 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 +1045,22 @@ export function parseScanDraft(input: ScanDraftInput): ScanDraftInput { export function parsePersistedScanDraft( input: Record ): 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); + return parsePersistedDraft(structuredClone(input), scanDraftInputSchema); +} + +function parsePersistedDraft(compatible: Record, schema: z.ZodType): ScanDraftInput { + 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 +1087,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..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,9 +1,11 @@ +import type { ScanDraftInput } from "../artifact-scan-draft.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"; 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; + }; + // 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..5f634a70e --- /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, 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 () => 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/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 }); diff --git a/sdk/typescript/scripts/check-package.mjs b/sdk/typescript/scripts/check-package.mjs index 4db1cb7f2..348fbcec6 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", @@ -182,6 +183,7 @@ const distFiles = new Set( "config", "config-path", "contract", + "contract-path", "cost", "cost-model", "custom-validation", 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..c2c21734b --- /dev/null +++ b/sdk/typescript/scripts/fixtures/package-plugin-compatibility.mjs @@ -0,0 +1,215 @@ +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.scope.context = " "; + manifest.scan.threatModel = { + summary: "Archive input", + assumptions: [" "], + }; + 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; + finding.locations[0].path = "src/./extract.py"; + finding.locations[0].role = " "; + } + if (name === "coverage.json") { + document.openQuestions = [ + "What deployment controls apply?", + ]; + document.surfaces = ["HTTP API", "ArchiveSurface"].map( + (id) => ({ + ...document.surfaces[0], + id, + notes: " ", + 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)); + } + 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); + 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.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( + (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( + { + 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/accepted-audit.ts b/sdk/typescript/src/accepted-audit.ts new file mode 100644 index 000000000..fe3eccb74 --- /dev/null +++ b/sdk/typescript/src/accepted-audit.ts @@ -0,0 +1,35 @@ +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 type AuditOutcome = { + execution: Execution; + checkpoint?: ScanDraftInput; +} & ( + { 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; +}): Promise> { + input.signal.throwIfAborted(); + const execution = await input.execute(); + input.signal.throwIfAborted(); + const checkpoint = await input.accept(execution); + input.signal.throwIfAborted(); + if (checkpoint === undefined) return { execution, status: "checkpoint" }; + // Process completion alone does not accept an unfinished audit checkpoint. + return checkpoint.complete === false + ? { 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 c61631dc9..b35f015a3 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -1,5 +1,7 @@ /// +import { runAcceptedAudit, type ScanDraftInput } from "./accepted-audit.js"; +import { pathToFileURL } from "node:url"; import { statSync } from "node:fs"; import { chmod, @@ -1919,6 +1921,7 @@ export class CodexSecurity { events, signal, scanDir, + scanId, pluginRoot: runtime.plugin.installedRoot, expectation, authentication, @@ -2148,6 +2151,7 @@ export class CodexSecurity { events: (await followUp()).events, signal, scanDir, + scanId, pluginRoot: runtime.plugin.installedRoot, expectation, model, @@ -3605,6 +3609,7 @@ async function removeTargetPathsFile(path: string | null): Promise { } interface ScanEventRunOptions { + scanId?: string; thread: CodexThreadLike; events: AsyncGenerator; signal: AbortSignal; @@ -3638,108 +3643,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 (): 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( + async (name) => + JSON.parse( + ( + await readScanFile(options.scanDir, name, name, options.signal) + ).toString("utf8"), + ), + ), ); - } - if (threadId === null) { + // Admission uses the SDK parser even when executing an older plugin. + const helper = ( + await import( + pathToFileURL(join(await bundledPluginRoot(), "mcp/helpers.mjs")).href + ) + ).default; + return helper.parseCanonicalScanDraft({ + scanId: options.scanId ?? manifest.scan.id, + manifest, + findings, + coverage, + }); + }; + 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/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 new file mode 100644 index 000000000..4fe0ea5a8 --- /dev/null +++ b/sdk/typescript/tests-ts/api-audit-admission.test.ts @@ -0,0 +1,520 @@ +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, + parseCanonicalScanDraft, + parseScanDraft, + 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" + ? [] + : [ + { + 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( + 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(deepResult.status).toBe("succeeded"); + expect(acceptedPaths).toEqual([deepResult.worker.resultPath]); + const deepDraft: ScanDraftInput = await readDiscoveryAuditDraft( + artifacts, + deepResult.worker.resultPath, + scanId, + ); + 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."], + }); + } + } 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); + } + 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(repository, scanDir); + expect(manifest.scan.id).toBe("scan_example_001"); + expect(standard.error).toBe(standard.finalization); + expect(standard.finalizations).toBe(1); +}); + +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); + } + }, +); + +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 }, + { + path: "src/extract.py", + notes: "Checked", + questions: [17], + accepted: false, + }, +])( + "canonical fields retain their rules: %j", + async ({ path, notes, questions, 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 = " "; + 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)), + 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.coverage.openQuestions).toEqual(coverage.openQuestions); + 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; + live.coverage.openQuestions[1].followUpPrompt = "Describe controls."; + 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, + scanId?: string, +) { + 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: PLUGIN_ROOT, + 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, + }; +} + +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/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; }, diff --git a/sdk/typescript/tests-ts/build-plugin.test.ts b/sdk/typescript/tests-ts/build-plugin.test.ts index 617498d37..fd405916d 100644 --- a/sdk/typescript/tests-ts/build-plugin.test.ts +++ b/sdk/typescript/tests-ts/build-plugin.test.ts @@ -178,6 +178,92 @@ 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", + [ + "--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 () => {