diff --git a/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json b/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json index 04c30b9c..4015ea14 100644 --- a/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json +++ b/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codex-security", - "version": "0.1.20", + "version": "0.1.21", "description": "Codex Security workflows for security scans, analysis, and investigation.", "author": { "name": "OpenAI" diff --git a/sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py b/sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py index 5a7806c7..132d07e9 100644 --- a/sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py +++ b/sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py @@ -27,6 +27,7 @@ SCHEMA_VERSION = "1.0" PRODUCER_NAME = "codex-security-plugin" FINGERPRINT_ALGORITHM = "codex-security/v1" +REPORT_NAME_RE = re.compile(r"report\.md", re.IGNORECASE | re.ASCII) SARIF_SCHEMA = "https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/schemas/sarif-schema-2.1.0.json" SEVERITIES = {"critical", "high", "medium", "low", "informational"} CONFIDENCES = {"high", "medium", "low"} @@ -1011,7 +1012,8 @@ def _validate_derived_finding_identities( raise ContractError(f"{context}.findingId: does not match derived fingerprint identity") if finding.get("occurrenceId") != occurrence_id: raise ContractError(f"{context}.occurrenceId: does not match scan occurrence identity") - if finding.get("fingerprints") != fingerprints: + actual_fingerprints = _require_dict(finding, "fingerprints", context) + if any(actual_fingerprints.get(key) != value for key, value in fingerprints.items()): raise ContractError(f"{context}.fingerprints: does not match derived fingerprint") @@ -1904,6 +1906,82 @@ def _read_sealed_scan( return manifest, findings, coverage, findings_bytes +def write_report_projection(scan_dir: Path, schema_dir: Path | None = None) -> None: + """Refresh only an unsealed report, preserving authenticated historical reports.""" + scan_dir = _require_scan_directory(scan_dir) + manifest, findings, coverage, _ = _read_sealed_scan( + scan_dir, schema_dir, "report projection" + ) + artifact_paths = { + _require_safe_relative_path(artifact["path"], "sealed artifact path") + for artifact in manifest["scan"]["artifacts"] + } + if "report.md" in artifact_paths: + return + try: + output_metadata = (scan_dir / "report.md").stat(follow_symlinks=False) + except FileNotFoundError: + output_metadata = None + except OSError as exc: + raise ContractError("report.md: unable to inspect report output") from exc + same_file_artifacts: list[str] = [] + if output_metadata is not None: + for artifact_path in artifact_paths: + descriptor = open_scan_local_file_descriptor( + scan_dir, artifact_path, f"sealed artifact {artifact_path}" + ) + try: + artifact_metadata = os.fstat(descriptor) + finally: + os.close(descriptor) + if os.path.samestat(output_metadata, artifact_metadata): + same_file_artifacts.append(artifact_path) + if output_metadata is not None and same_file_artifacts: + try: + entries = set(os.listdir(scan_dir)) + report_entries = [name for name in entries if REPORT_NAME_RE.fullmatch(name)] + report_entry = None + if "report.md" in entries: + report_entry = "report.md" + elif len(report_entries) == 1: + report_entry = report_entries[0] + if report_entry is not None and report_entry != "report.md": + descriptor = open_scan_local_file_descriptor( + scan_dir, report_entry, "report directory entry" + ) + try: + if not os.path.samestat(output_metadata, os.fstat(descriptor)): + report_entry = None + finally: + os.close(descriptor) + if ( + report_entry is not None + and len(report_entries) == 1 + and any(REPORT_NAME_RE.fullmatch(path) for path in same_file_artifacts) + ): + return + root_metadata = scan_dir.stat(follow_symlinks=False) + for artifact_path in same_file_artifacts: + artifact = PurePosixPath(artifact_path) + parent = _require_scan_directory(scan_dir.joinpath(*artifact.parts[:-1])) + if not os.path.samestat(root_metadata, parent.stat(follow_symlinks=False)): + continue + if ( + report_entry is not None + and artifact.name in entries + and artifact.name != report_entry + ): + continue + raise ContractError( + "report.md: cannot safely replace an ambiguous sealed artifact alias" + ) + except OSError as exc: + raise ContractError("report.md: unable to inspect sealed artifact aliases") from exc + write_scan_local_bytes( + scan_dir, "report.md", _generate_report_projection(manifest, findings, coverage) + ) + + def build_sarif_projection( scan_dir: Path, source_root: Path | None = None, schema_dir: Path | None = None ) -> dict[str, Any]: @@ -2316,18 +2394,27 @@ def main() -> int: parser.add_argument("--schema-dir", type=Path) parser.add_argument("--source-root", type=Path) parser.add_argument("--sarif-only", action="store_true") + parser.add_argument("--report-only", action="store_true") parser.add_argument("--sarif-output", type=Path) parser.add_argument("--export-format", choices=sorted(EXPORT_PATHS)) parser.add_argument("--export-output", type=Path) args = parser.parse_args() try: + if args.report_only and ( + args.sarif_only or args.export_format is not None or args.source_root is not None + ): + parser.error( + "--report-only cannot be combined with SARIF, export, or source-root options" + ) if args.sarif_only and args.export_format is not None: parser.error("--sarif-only cannot be combined with --export-format") if args.export_output is not None and args.export_format is None: parser.error("--export-output requires --export-format") if args.sarif_output is not None and not args.sarif_only: parser.error("--sarif-output requires --sarif-only") - if args.export_format is not None: + if args.report_only: + write_report_projection(args.scan_dir, args.schema_dir) + elif args.export_format is not None: contents = build_findings_export( args.scan_dir, args.export_format, args.source_root, args.schema_dir ) diff --git a/sdk/typescript/src/contract.ts b/sdk/typescript/src/contract.ts index 559f17a1..29edbff8 100644 --- a/sdk/typescript/src/contract.ts +++ b/sdk/typescript/src/contract.ts @@ -3,6 +3,7 @@ import { constants, type Stats } from "node:fs"; import { lstat, open, + readdir, readFile, realpath, type FileHandle, @@ -27,6 +28,7 @@ const DOCUMENTS = { "coverage.json": "coverage.schema.json", } as const; const PRODUCER_NAME = "codex-security-plugin"; +const REPORT_NAME = /^[rR][eE][pP][oO][rR][tT]\.[mM][dD]$/u; const SAFE_SCHEMA_ERROR_PROPERTIES = new Set([ "scan", "target", @@ -291,6 +293,64 @@ export async function requireScanFile( ).path; } +// The manifest must already have passed loadContract's seal validation. +export async function hasSealedReport( + scanDirectory: string, + manifest: ScanManifest, + signal?: AbortSignal, +): Promise { + try { + throwIfAborted(signal); + const artifactPaths = manifest.scan.artifacts + .map((artifact, index) => + safeRelativePath( + artifact.path, + `manifest.scan.artifacts[${index}].path`, + ), + ) + .filter((path) => REPORT_NAME.test(path)); + if (artifactPaths.includes("report.md")) return true; + if (artifactPaths.length === 0) return false; + + const scanRoot = await requireScanRoot(scanDirectory, signal); + const reportEntries = (await readdir(scanRoot.path)).filter((name) => + REPORT_NAME.test(name), + ); + throwIfAborted(signal); + if (reportEntries.length !== 1) return false; + const report = await requireCheckedScanFile( + scanRoot.path, + "report.md", + "report.md", + signal, + scanRoot, + ); + for (const artifactPath of artifactPaths) { + const file = await openCheckedScanFile( + scanRoot.path, + artifactPath, + `sealed artifact ${artifactPath}`, + signal, + scanRoot, + ); + try { + const sameFile = await sameCheckedFileDevice( + file, + report, + await file.stat(), + ); + throwIfAborted(signal); + if (sameFile) return true; + } finally { + await file.close(); + } + } + } catch { + throwIfAborted(signal); + } + return false; +} + async function requireCheckedScanFile( scanDirectory: string, relativePath: string, diff --git a/sdk/typescript/src/multiscan.ts b/sdk/typescript/src/multiscan.ts index 63f3a790..ef680a16 100644 --- a/sdk/typescript/src/multiscan.ts +++ b/sdk/typescript/src/multiscan.ts @@ -1,5 +1,5 @@ import { execFile as execFileCallback } from "node:child_process"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { lstat, mkdir, @@ -14,17 +14,35 @@ import { writeFile, } from "node:fs/promises"; import { hostname } from "node:os"; -import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { + dirname, + isAbsolute, + join, + posix, + relative, + resolve, + sep, +} from "node:path"; import { promisify } from "node:util"; import Papa from "papaparse"; import type { CodexSecurity } from "./api.js"; import type { CodexSecurityConfig } from "./config.js"; +import { hasSealedReport, loadContract } from "./contract.js"; import type { ScanCost } from "./cost.js"; import { safeErrorMessage, ScanCostLimitExceededError } from "./errors.js"; import type { CoverageDocument } from "./models.js"; -import { requireSecureOutputAncestry } from "./runtime.js"; -import type { ScanMode } from "./targets.js"; -import { resolveTrustedExecutable } from "./trusted-executable.js"; +import { + bundledPluginRoot, + pluginHelperEnvironment, + requireSecureOutputAncestry, + resolvePluginPath, + resolvePluginPythonCommand, +} from "./runtime.js"; +import { outermostGitMarkerRoot, type ScanMode } from "./targets.js"; +import { + resolveTrustedExecutable, + type TrustedExecutable, +} from "./trusted-executable.js"; const execFile = promisify(execFileCallback); const REQUIRED_ARTIFACTS = [ @@ -49,10 +67,14 @@ interface MultiscanReceipt extends MultiscanTask { status: "completed" | "completed_with_incomplete_coverage" | "failed"; attempt: number; outputDir: string; + targetId?: string; + resolvedScope?: string; + snapshotDigest?: string; coverage?: CoverageDocument["completeness"]; cost?: ScanCost; error?: string; warning?: string; + warnings?: string[]; } export interface MultiscanOptions { @@ -89,6 +111,7 @@ export interface MultiscanResult { completed: number; incomplete: number; failed: number; + warned: number; skipped: number; resultsPath: string; } @@ -112,12 +135,36 @@ export async function runMultiscan( const output = await ensureOutputDirectory(requestedOutput); await requireSecureOutputAncestry(output); const unlock = await acquireLock(output); + let pluginWorkspace: string | undefined; + let pluginRoot: Promise | undefined; + const resolveResumePluginRoot = (): Promise => + (pluginRoot ??= (async () => { + if (options.config.pluginPath !== undefined) { + pluginWorkspace = join(output, `.resume-plugin-${randomUUID()}`); + await mkdir(pluginWorkspace, { mode: 0o700 }); + } + return await resolvePluginPath( + options.config.pluginPath, + pluginWorkspace ?? output, + options.signal, + ); + })()); try { - const result = await runCampaign(options, tasks, output); + const result = await runCampaign( + options, + tasks, + output, + resolveResumePluginRoot, + ); return (await realpath(requestedOutput).catch(() => undefined)) === output ? { ...result, resultsPath: join(requestedOutput, "results.jsonl") } : result; } finally { + if (pluginWorkspace !== undefined) { + await rm(pluginWorkspace, { recursive: true, force: true }).catch( + () => undefined, + ); + } await unlock(); } } @@ -126,57 +173,148 @@ async function runCampaign( options: MultiscanOptions, tasks: MultiscanTask[], output: string, + resolveResumePluginRoot: () => Promise, ): Promise { const ledger = join(output, "results.jsonl"); await ensureOutputDirectory(join(output, "checkouts")); await ensureOutputDirectory(join(output, "artifacts")); await ensureManifest(join(output, "manifest.json"), tasks, options); - const receipts = await readReceipts(ledger); + const { receipts, warnedIds } = await readReceipts(ledger, tasks); const pending: MultiscanTask[] = []; + let reportRuntime: Promise<[TrustedExecutable, string]> | undefined; + const restoreReport = async ( + scanDir: string, + schemaPluginRoot: string, + ): Promise => { + try { + // Configured historical archives may contain schemas without helper scripts. + const [python, helperRoot] = await (reportRuntime ??= (async () => { + const repositories = [ + process.cwd(), + ...tasks.map((task) => task.repository).filter(isAbsolute), + ]; + const repositoryRoots = await Promise.all( + [...new Set(repositories)].map(async (repository) => { + const canonical = await realpath(repository).catch(() => + resolve(repository), + ); + const metadata = await lstat(canonical).catch(() => undefined); + return metadata?.isDirectory() + ? await outermostGitMarkerRoot(canonical, options.signal) + : canonical; + }), + ); + return await Promise.all([ + resolvePluginPythonCommand({ + configuredPath: options.config.pythonPath, + environment: pluginHelperEnvironment(process.env), + additionalProtectedRoots: [output, ...repositoryRoots], + signal: options.signal, + }), + bundledPluginRoot(), + ]); + })()); + await execFile( + python.executable, + [ + "-I", + "-B", + join(helperRoot, "scripts", "finalize_scan_contract.py"), + "--scan-dir", + scanDir, + "--schema-dir", + join(schemaPluginRoot, "schemas"), + "--report-only", + ], + { + env: python.environment, + maxBuffer: Infinity, + windowsHide: true, + signal: options.signal, + }, + ); + options.signal?.throwIfAborted(); + } catch (error) { + if (options.signal?.aborted) options.signal.throwIfAborted(); + throw new Error( + `Multiscan report recovery is required: ${safeErrorMessage(error)}`, + ); + } + }; let completed = 0; let incomplete = 0; for (const task of tasks) { const receipt = receipts.get(task.id.toLowerCase()); - if (receipt === undefined) { + if (receipt === undefined || !matchesTask(receipt, task)) { pending.push(task); continue; } const artifactRoot = await ensureOutputDirectory( join(output, "artifacts", task.id), ); - const artifactOutput = join(artifactRoot, `attempt-${receipt.attempt}`); + const attemptName = `attempt-${receipt.attempt}`; + const artifactOutput = join(artifactRoot, attemptName); const selectedArtifactOutput = join( resolve(options.outputDir), "artifacts", task.id, - `attempt-${receipt.attempt}`, + attemptName, ); if ( - (receipt.outputDir === artifactOutput || - receipt.outputDir === selectedArtifactOutput) && - (await hasArtifacts(artifactOutput)) + receipt.outputDir === artifactOutput || + receipt.outputDir === selectedArtifactOutput ) { - if (receipt.status === "completed") { - completed += 1; + const canonicalArtifactOutput = await realpath(artifactOutput).catch( + (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT" || error.code === "ENOTDIR") { + return undefined; + } + throw error; + }, + ); + if (canonicalArtifactOutput === undefined) { + pending.push(task); continue; } - const coverage = - receipt.status === "completed_with_incomplete_coverage" - ? receipt.coverage ?? "unknown" - : await legacyIncompleteCoverage({ - ...receipt, - outputDir: artifactOutput, - }); - if (coverage !== undefined) { - incomplete += 1; - notifyProgress(options, { - repository: task.id, - status: "completed_with_incomplete_coverage", - attempt: receipt.attempt, - warning: - receipt.warning ?? - `Scan coverage is ${coverage}; results may be incomplete.`, - }); + if ( + relative( + join(output, "artifacts", task.id, attemptName), + canonicalArtifactOutput, + ) !== "" + ) { + throw new Error( + "Multiscan recovery is required: saved artifacts are outside their expected campaign directory.", + ); + } + const checkout = join(output, "checkouts", task.id); + const schemaPluginRoot = await resolveResumePluginRoot(); + const resumed = await loadResumableScan( + artifactOutput, + schemaPluginRoot, + receipt, + checkout, + options.signal, + ); + if (resumed !== undefined) { + if (!resumed.reportSealed) { + await restoreReport(canonicalArtifactOutput, schemaPluginRoot); + } + await rm(checkout, { + recursive: true, + force: true, + }).catch(() => undefined); + if (resumed.completeness === "complete") completed += 1; + else { + incomplete += 1; + notifyProgress(options, { + repository: task.id, + status: "completed_with_incomplete_coverage", + attempt: receipt.attempt, + warning: + receipt.warning ?? + `Scan coverage is ${resumed.completeness}; results may be incomplete.`, + }); + } continue; } } @@ -189,6 +327,7 @@ async function runCampaign( completed, incomplete, failed: 0, + warned: warnedIds.size, skipped, resultsPath: ledger, }; @@ -203,10 +342,16 @@ async function runCampaign( options.signal?.throwIfAborted(); const task = pending[next++]; if (task === undefined) return; - let attempt = receipts.get(task.id.toLowerCase())?.attempt ?? 0; + const taskId = task.id.toLowerCase(); + let attempt = receipts.get(taskId)?.attempt ?? 0; for (let retry = 0; retry < options.maxAttempts; retry += 1) { options.signal?.throwIfAborted(); attempt += 1; + if (!Number.isSafeInteger(attempt)) { + throw new Error( + "Multiscan recovery is required: the next attempt is not a safe integer.", + ); + } const checkout = join(output, "checkouts", task.id); const scanDir = join( output, @@ -218,9 +363,23 @@ async function runCampaign( notifyProgress(options, { ...progress, status: "started" }); let failure: string | undefined; let warning: string | undefined; + let targetId: string | undefined; + let resolvedScope: string | undefined; + let snapshotDigest: string | undefined; let coverage: CoverageDocument["completeness"] | undefined; let cost: Readonly | null = null; let exhaustedBudget = false; + const warnings: string[] = []; + const recordWarning = (message: unknown): void => { + const safeWarning = safeErrorMessage(message); + warnings.push(safeWarning); + warnedIds.add(taskId); + notifyProgress(options, { + ...progress, + status: "started", + warning: safeWarning, + }); + }; try { await ensureOutputDirectory(dirname(scanDir)); await rm(checkout, { recursive: true, force: true }); @@ -233,7 +392,8 @@ async function runCampaign( ); if (task.scope !== undefined) { const scoped = await realpath(join(checkout, task.scope)); - const outside = relative(await realpath(checkout), scoped); + const canonicalCheckout = await realpath(checkout); + const outside = relative(canonicalCheckout, scoped); if ( outside === ".." || outside.startsWith(`..${sep}`) || @@ -241,6 +401,7 @@ async function runCampaign( ) { throw new Error("Multiscan scope escapes its repository."); } + resolvedScope = outside.split(sep).join("/") || "."; } const scanPrompt = [options.scanPrompt?.trim(), task.prompt] .filter(Boolean) @@ -259,15 +420,12 @@ async function runCampaign( ...(options.maxCostUsd === undefined ? {} : { maxCostUsd: options.maxCostUsd }), - onWarning: (warning) => - notifyProgress(options, { - ...progress, - status: "started", - warning, - }), + onWarning: recordWarning, ...(options.signal === undefined ? {} : { signal: options.signal }), }); cost = result.cost; + targetId = result.manifest.scan.target.targetId; + snapshotDigest = result.manifest.scan.target.snapshotDigest; coverage = result.coverage.completeness; if (coverage !== "complete") { if (!(await hasArtifacts(scanDir))) { @@ -285,7 +443,13 @@ async function runCampaign( } failure = safeErrorMessage(error); } finally { - await rm(checkout, { recursive: true, force: true }); + await rm(checkout, { recursive: true, force: true }).catch( + (error: unknown) => { + recordWarning( + `Multiscan checkout cleanup failed: ${safeErrorMessage(error)}`, + ); + }, + ); } const status = failure !== undefined @@ -300,10 +464,14 @@ async function runCampaign( status, attempt, outputDir: scanDir, + ...(targetId === undefined ? {} : { targetId }), + ...(resolvedScope === undefined ? {} : { resolvedScope }), + ...(snapshotDigest === undefined ? {} : { snapshotDigest }), ...(coverage === undefined ? {} : { coverage }), ...(cost === null ? {} : { cost }), ...(failure === undefined ? {} : { error: failure }), ...(warning === undefined ? {} : { warning }), + ...(warnings.length === 0 ? {} : { warnings }), })}\n`, ); notifyProgress(options, { @@ -345,6 +513,7 @@ async function runCampaign( completed, incomplete, failed, + warned: warnedIds.size, skipped, resultsPath: ledger, }; @@ -623,14 +792,94 @@ async function ensureManifest( } } +function matchesTask(receipt: MultiscanReceipt, task: MultiscanTask): boolean { + return ( + receipt.id === task.id && + receipt.repository === task.repository && + receipt.revision === task.revision && + receipt.mode === task.mode && + receipt.scope === task.scope && + receipt.prompt === task.prompt + ); +} + +function isReceiptRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseReceipt(line: string, lineNumber: number): MultiscanReceipt { + let value: unknown; + try { + value = JSON.parse(line); + } catch { + value = undefined; + } + const cost = isReceiptRecord(value) ? value["cost"] : undefined; + if ( + !isReceiptRecord(value) || + !["id", "repository", "revision", "outputDir"].every( + (field) => typeof value[field] === "string", + ) || + (value["mode"] !== "standard" && value["mode"] !== "deep") || + !["completed", "completed_with_incomplete_coverage", "failed"].includes( + value["status"] as string, + ) || + typeof value["attempt"] !== "number" || + !Number.isSafeInteger(value["attempt"]) || + value["attempt"] < 1 || + ![ + "scope", + "prompt", + "targetId", + "resolvedScope", + "snapshotDigest", + "error", + "warning", + ].every( + (field) => value[field] === undefined || typeof value[field] === "string", + ) || + (value["coverage"] !== undefined && + !["complete", "partial", "unknown"].includes( + value["coverage"] as string, + )) || + (value["warnings"] !== undefined && + (!Array.isArray(value["warnings"]) || + !value["warnings"].every((warning) => typeof warning === "string"))) || + (cost !== undefined && + (!isReceiptRecord(cost) || + typeof cost["model"] !== "string" || + ![ + "inputTokens", + "cachedInputTokens", + "cacheWriteInputTokens", + "outputTokens", + "estimatedUsd", + ].every( + (field) => + typeof cost[field] === "number" && Number.isFinite(cost[field]), + ))) + ) { + throw new Error( + `Multiscan recovery is required: results line ${lineNumber} is not a valid receipt.`, + ); + } + return value as unknown as MultiscanReceipt; +} + async function readReceipts( path: string, -): Promise> { + tasks: readonly MultiscanTask[], +): Promise<{ + receipts: Map; + warnedIds: Set; +}> { let contents: string; try { contents = await readFile(path, "utf8"); } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return new Map(); + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { receipts: new Map(), warnedIds: new Set() }; + } throw error; } const lines = contents.split("\n"); @@ -641,12 +890,98 @@ async function readReceipts( Buffer.byteLength(contents) - Buffer.byteLength(partial), ); } - return new Map( - lines.filter(Boolean).map((line): [string, MultiscanReceipt] => { - const receipt = JSON.parse(line) as MultiscanReceipt; - return [receipt.id.toLowerCase(), receipt]; - }), + const receipts = new Map(); + const warnedIds = new Set(); + const indexedTasks = new Map( + tasks.map((task) => [task.id.toLowerCase(), task]), ); + for (const [index, line] of lines.entries()) { + if (!line) continue; + const receipt = parseReceipt(line, index + 1); + const id = receipt.id.toLowerCase(); + receipts.set(id, receipt); + const task = indexedTasks.get(id); + if ( + task !== undefined && + matchesTask(receipt, task) && + Array.isArray(receipt.warnings) && + receipt.warnings.length > 0 + ) { + warnedIds.add(id); + } + } + return { receipts, warnedIds }; +} + +async function loadResumableScan( + path: string, + pluginRoot: string, + receipt: MultiscanReceipt, + checkout: string, + signal?: AbortSignal, +): Promise< + | { + completeness: CoverageDocument["completeness"]; + reportSealed: boolean; + } + | undefined +> { + try { + const { manifest, coverage } = await loadContract(path, { + pluginRoot, + signal, + }); + const { target, scope, producer } = manifest.scan; + const targetId = + receipt.targetId ?? + `target_sha256_${createHash("sha256") + .update(`local-workspace\0${checkout}`) + .digest("hex")}`; + const expectedScope = + receipt.scope === undefined + ? "." + : receipt.resolvedScope ?? + posix.normalize(receipt.scope).replace(/\/+$/, ""); + const expectedMode = + receipt.scope !== undefined + ? "scoped_path" + : receipt.mode === "deep" + ? "deep_repository" + : "repository"; + const expectedKind = + receipt.snapshotDigest === undefined ? "git_revision" : "git_worktree"; + if ( + producer.name !== "codex-security-plugin" || + target.targetId !== targetId || + target.kind !== expectedKind || + target.snapshotDigest !== receipt.snapshotDigest || + target.displayName !== receipt.id || + target.revision !== receipt.revision || + coverage.mode !== expectedMode || + scope.includePaths.length !== 1 || + scope.includePaths[0] !== expectedScope || + scope.excludePaths.length !== 0 + ) { + return undefined; + } + const completeness = coverage.completeness; + const matchesOutcome = + completeness === "complete" + ? receipt.status === "completed" + : (receipt.status === "completed_with_incomplete_coverage" && + (receipt.coverage ?? completeness) === completeness) || + (receipt.status === "failed" && + receipt.error === "Multiscan repository coverage is incomplete."); + return matchesOutcome + ? { + completeness, + reportSealed: await hasSealedReport(path, manifest, signal), + } + : undefined; + } catch { + if (signal?.aborted === true) signal.throwIfAborted(); + return undefined; + } } async function hasArtifacts(path: string): Promise { @@ -661,28 +996,6 @@ async function hasArtifacts(path: string): Promise { } } -async function legacyIncompleteCoverage( - receipt: MultiscanReceipt, -): Promise | undefined> { - if ( - receipt.status !== "failed" || - receipt.error !== "Multiscan repository coverage is incomplete." - ) { - return undefined; - } - try { - const coverage = JSON.parse( - await readFile(join(receipt.outputDir, "coverage.json"), "utf8"), - ) as { completeness?: unknown }; - return coverage.completeness === "partial" || - coverage.completeness === "unknown" - ? coverage.completeness - : undefined; - } catch { - return undefined; - } -} - function parseInventory( source: string, directory: string, diff --git a/sdk/typescript/src/runtime.ts b/sdk/typescript/src/runtime.ts index 13c41f2c..27f19226 100644 --- a/sdk/typescript/src/runtime.ts +++ b/sdk/typescript/src/runtime.ts @@ -38,7 +38,10 @@ import { errorMessage, } from "./errors.js"; import type { JsonObject } from "./config.js"; -import { resolveTrustedExecutable } from "./trusted-executable.js"; +import { + resolveTrustedExecutable, + type TrustedExecutable, +} from "./trusted-executable.js"; const execFile = promisify(execFileCallback); @@ -84,6 +87,7 @@ export interface PluginPythonOptions { homeDirectory?: string; managedRuntimeRoots?: readonly string[]; protectedRoot?: string; + additionalProtectedRoots?: readonly string[]; signal?: AbortSignal; } @@ -1301,15 +1305,7 @@ export async function runWorkbench( ...args, ], { - env: Object.fromEntries( - Object.entries(options.environment).filter( - ([name]) => - name.toUpperCase() !== "OPENAI_API_KEY" && - name.toUpperCase() !== "CODEX_API_KEY" && - name.toUpperCase() !== "OPENROUTER_API_KEY" && - name.toUpperCase() !== "FIREWORKS_API_KEY", - ), - ), + env: pluginHelperEnvironment(options.environment), encoding: "utf8", maxBuffer: Infinity, windowsHide: true, @@ -2156,14 +2152,25 @@ export async function pluginMetadata( export async function resolvePluginPython( options: PluginPythonOptions = {}, ): Promise { + return (await resolvePluginPythonCommand(options)).executable; +} + +export async function resolvePluginPythonCommand( + options: PluginPythonOptions = {}, +): Promise { const environment = options.environment ?? process.env; - const protectedRoot = options.protectedRoot ?? process.cwd(); + const protectedRoots = [ + ...new Set([ + options.protectedRoot ?? process.cwd(), + ...(options.additionalProtectedRoots ?? []), + ]), + ]; if (options.configuredPath !== undefined) { return await requirePython( options.configuredPath, "configured plugin Python", environment, - protectedRoot, + protectedRoots, options.signal, ); } @@ -2173,7 +2180,7 @@ export async function resolvePluginPython( inherited, "PYTHON", environment, - protectedRoot, + protectedRoots, options.signal, ); } @@ -2199,7 +2206,7 @@ export async function resolvePluginPython( const resolved = await usablePython( candidate, environment, - protectedRoot, + protectedRoots, options.signal, ); if (resolved !== null) return resolved; @@ -2212,7 +2219,7 @@ export async function resolvePluginPython( const resolved = await usablePython( candidate, environment, - protectedRoot, + protectedRoots, options.signal, ); if (resolved !== null) return resolved; @@ -2223,6 +2230,20 @@ export async function resolvePluginPython( ); } +export function pluginHelperEnvironment( + environment: ProcessEnvironment, +): ProcessEnvironment { + return Object.fromEntries( + Object.entries(environment).filter( + ([name]) => + name.toUpperCase() !== "OPENAI_API_KEY" && + name.toUpperCase() !== "CODEX_API_KEY" && + name.toUpperCase() !== "OPENROUTER_API_KEY" && + name.toUpperCase() !== "FIREWORKS_API_KEY", + ), + ); +} + export function pluginExecutionEnvironment( python: string, environment: ProcessEnvironment = process.env, @@ -2414,13 +2435,13 @@ async function requirePython( candidate: string, source: string, environment: ProcessEnvironment, - protectedRoot: string, + protectedRoots: readonly string[], signal?: AbortSignal, -): Promise { +): Promise { const resolved = await usablePython( candidate, environment, - protectedRoot, + protectedRoots, signal, ); if (resolved !== null) return resolved; @@ -2432,15 +2453,24 @@ async function requirePython( async function usablePython( candidate: string, - environment: ProcessEnvironment = process.env, - protectedRoot: string = process.cwd(), + environment: ProcessEnvironment, + protectedRoots: readonly string[], signal?: AbortSignal, -): Promise { - const command = await resolveTrustedExecutable( - isPythonPathCandidate(candidate) ? expandHome(candidate) : candidate, - environment, - protectedRoot, - ); +): Promise { + const original = isPythonPathCandidate(candidate) + ? expandHome(candidate) + : candidate; + let command: TrustedExecutable | null = null; + // Preserve the invocation name while each root further filters the same PATH. + for (const protectedRoot of protectedRoots) { + throwIfSignalAborted(signal); + command = await resolveTrustedExecutable( + original, + command?.environment ?? environment, + protectedRoot, + ); + if (command === null) return null; + } if (command === null) return null; try { const { stdout } = await execFile( @@ -2458,9 +2488,7 @@ async function usablePython( signal, }, ); - return stdout.trim() === "codex-security-python-ok" - ? command.executable - : null; + return stdout.trim() === "codex-security-python-ok" ? command : null; } catch (error) { if (signal?.aborted) throw error; return null; diff --git a/sdk/typescript/src/targets.ts b/sdk/typescript/src/targets.ts index f13858af..025052e1 100644 --- a/sdk/typescript/src/targets.ts +++ b/sdk/typescript/src/targets.ts @@ -418,7 +418,7 @@ async function gitOutput( return stdout.trim(); } -async function outermostGitMarkerRoot( +export async function outermostGitMarkerRoot( repository: string, signal?: AbortSignal, ): Promise { diff --git a/sdk/typescript/src/version.ts b/sdk/typescript/src/version.ts index 01dd5006..955feef3 100644 --- a/sdk/typescript/src/version.ts +++ b/sdk/typescript/src/version.ts @@ -8,7 +8,7 @@ const PACKAGE_VERSIONS = packageVersions( export const VERSION = PACKAGE_VERSIONS.package; export const CODEX_SDK_VERSION = PACKAGE_VERSIONS.sdk; export const CODEX_EXECUTABLE_VERSION = PACKAGE_VERSIONS.executable; -export const BUNDLED_PLUGIN_VERSION = "0.1.20" as const; +export const BUNDLED_PLUGIN_VERSION = "0.1.21" as const; const PACKAGE_NAME = "@openai/codex-security"; const VERSION_PATTERN = diff --git a/sdk/typescript/tests-ts/contract.test.ts b/sdk/typescript/tests-ts/contract.test.ts index 4fdfcd05..0468759e 100644 --- a/sdk/typescript/tests-ts/contract.test.ts +++ b/sdk/typescript/tests-ts/contract.test.ts @@ -12,13 +12,20 @@ import { type FileHandle, writeFile, } from "node:fs/promises"; +import * as fsPromises from "node:fs/promises"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { afterEach, describe, expect, test } from "bun:test"; +import { dirname, join, resolve } from "node:path"; +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; import { ContractValidationError, loadContract } from "../src/index.js"; -import { sameCheckedFileDevice } from "../src/contract.js"; -import type { NormalizedTarget, ScanExpectation } from "../src/index.js"; +import { hasSealedReport, sameCheckedFileDevice } from "../src/contract.js"; +import type { + NormalizedTarget, + ScanExpectation, + ScanManifest, +} from "../src/index.js"; +import * as runtime from "../src/runtime.js"; import { PLUGIN_ROOT } from "./plugin-root.js"; +import { runMockInSubprocess } from "./support/isolated-mock.js"; const EXAMPLE = join(PLUGIN_ROOT, "examples", "completed-scan"); const temporaryDirectories: string[] = []; @@ -105,6 +112,136 @@ function expectation( } describe("canonical scan contract", () => { + test("recognizes only the authenticated report directory entry", async () => { + if ( + runMockInSubprocess( + import.meta.path, + "recognizes only the authenticated report directory entry", + ) + ) { + return; + } + const root = resolve("saved-scan"); + const original = { ...fsPromises }; + const metadata = (ino: number, directory = false): Stats => + ({ + dev: 1, + ino, + isDirectory: () => directory, + isFile: () => !directory, + isSymbolicLink: () => false, + }) as Stats; + let entries: string[] = []; + let artifactInode = 7; + let missingReport = false; + let onList: (() => void) | undefined; + let opened = 0; + let closed = 0; + const inspected = (path: unknown): Stats => { + const absolute = resolve(String(path)); + if (absolute === root) return metadata(1, true); + if (dirname(absolute) !== root) throw new Error("Unexpected mock path."); + if (missingReport && absolute === join(root, "report.md")) { + throw Object.assign(new Error("Missing mock report."), { + code: "ENOENT", + }); + } + return metadata(absolute === join(root, "REPORT.md") ? artifactInode : 7); + }; + const privateOutput = spyOn( + runtime, + "requirePrivateOutputDirectory", + ).mockImplementation(() => {}); + const secureAncestry = spyOn( + runtime, + "requireSecureOutputAncestry", + ).mockResolvedValue(undefined); + mock.module("node:fs/promises", () => ({ + ...original, + lstat: async (path: unknown) => inspected(path), + realpath: async (path: unknown) => resolve(String(path)), + readdir: async () => { + onList?.(); + return entries; + }, + open: async (path: unknown) => { + const fileMetadata = inspected(path); + opened += 1; + return { + stat: async () => fileMetadata, + close: async () => { + closed += 1; + }, + }; + }, + })); + const manifest = (paths: string[]): ScanManifest => + ({ + scan: { artifacts: paths.map((path) => ({ path })) }, + }) as unknown as ScanManifest; + try { + const cases: Array<{ + paths: string[]; + entries: string[]; + expected: boolean; + inode?: number; + missing?: boolean; + }> = [ + { paths: ["./report.md"], entries: [], expected: true }, + { + paths: ["findings.json", "coverage.json"], + entries: ["report.md", "findings.json", "coverage.json"], + expected: false, + }, + { paths: ["REPORT.md"], entries: ["REPORT.md"], expected: true }, + { paths: ["REPORT.md"], entries: ["report.md"], expected: true }, + { + paths: ["REPORT.md"], + entries: ["REPORT.md", "report.md"], + expected: false, + }, + { + paths: ["REPORT.md"], + entries: ["REPORT.md"], + inode: 8, + expected: false, + }, + { + paths: ["REPORT.md"], + entries: ["REPORT.md"], + missing: true, + expected: false, + }, + { paths: ["REPORT.md"], entries: [], expected: false }, + ]; + for (const value of cases) { + entries = value.entries; + artifactInode = value.inode ?? 7; + missingReport = value.missing ?? false; + expect(await hasSealedReport(root, manifest(value.paths))).toBe( + value.expected, + ); + expect(closed).toBe(opened); + } + entries = []; + for (const throwDuringList of [false, true]) { + const controller = new AbortController(); + const reason = new Error("Report inspection cancelled."); + onList = () => { + controller.abort(reason); + if (throwDuringList) throw reason; + }; + await expect( + hasSealedReport(root, manifest(["REPORT.md"]), controller.signal), + ).rejects.toBe(reason); + } + } finally { + mock.module("node:fs/promises", () => original); + privateOutput.mockRestore(); + secureAncestry.mockRestore(); + } + }); + test("compares exact Windows volume serials without rounding file identity", async () => { const scanDir = await copyExample(); const path = join(scanDir, "scan-manifest.json"); diff --git a/sdk/typescript/tests-ts/multiscan.test.ts b/sdk/typescript/tests-ts/multiscan.test.ts index 33676192..b2bd3619 100644 --- a/sdk/typescript/tests-ts/multiscan.test.ts +++ b/sdk/typescript/tests-ts/multiscan.test.ts @@ -1,8 +1,10 @@ import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { access, appendFile, chmod, + cp, lstat, mkdir, mkdtemp, @@ -17,14 +19,21 @@ import { } from "node:fs/promises"; import * as filesystem from "node:fs/promises"; import { hostname, tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, dirname, join, posix, relative, sep } from "node:path"; import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { zipSync } from "fflate"; +import Papa from "papaparse"; import { main } from "../src/cli.js"; +import { loadContract } from "../src/contract.js"; +import * as contract from "../src/contract.js"; import { ScanCostLimitExceededError } from "../src/errors.js"; import type { ScanResult } from "../src/result.js"; import { buildGitHubCredentialArgs, runMultiscan } from "../src/multiscan.js"; +import * as runtime from "../src/runtime.js"; +import { outermostGitMarkerRoot } from "../src/targets.js"; import { resolveTrustedExecutable } from "../src/trusted-executable.js"; import { capture, dependencies, fakeResult } from "./cli-fixtures.js"; +import { PLUGIN_ROOT } from "./plugin-root.js"; type MultiscanOptions = Parameters[0]; type SecurityClient = ReturnType; @@ -91,14 +100,127 @@ async function repository( async function completedScan( outputDir: string, completeness: "complete" | "partial" | "unknown" = "complete", + targetKind: "git_revision" | "git_worktree" = "git_revision", ): Promise { - await mkdir(outputDir, { recursive: true }); - await Promise.all( - ["scan-manifest.json", "findings.json", "coverage.json", "report.md"].map( - (name) => writeFile(join(outputDir, name), "{}\n"), - ), + await mkdir(outputDir, { recursive: true, mode: 0o700 }); + await cp(join(PLUGIN_ROOT, "examples", "completed-scan"), outputDir, { + recursive: true, + }); + await writeFile(join(outputDir, "report.md"), "# Scan report\n"); + const manifestPath = join(outputDir, "scan-manifest.json"); + const findingsPath = join(outputDir, "findings.json"); + const coveragePath = join(outputDir, "coverage.json"); + const manifest = JSON.parse( + await readFile(manifestPath, "utf8"), + ) as ScanResult["manifest"]; + const findings = JSON.parse( + await readFile(findingsPath, "utf8"), + ) as ScanResult["findings"]; + const coverage = JSON.parse( + await readFile(coveragePath, "utf8"), + ) as ScanResult["coverage"]; + const id = basename(dirname(outputDir)); + const campaignRoot = dirname(dirname(dirname(outputDir))); + const fixtureRoot = temporaryDirectories.find((root) => + outputDir.startsWith(`${root}${sep}`), ); - return { coverage: { completeness } } as ScanResult; + const inventory = + fixtureRoot === undefined + ? undefined + : await readFile(join(fixtureRoot, "repositories.csv"), "utf8").catch( + () => undefined, + ); + if (inventory !== undefined) { + const task = Papa.parse>(inventory, { + header: true, + skipEmptyLines: true, + }).data.find((entry) => entry["id"] === id); + if (task !== undefined) { + manifest.scan.target.kind = targetKind; + manifest.scan.target.targetId = `target_sha256_${createHash("sha256") + .update(`local-workspace\0${join(campaignRoot, "checkouts", id)}`) + .digest("hex")}`; + manifest.scan.target.displayName = id; + manifest.scan.target.revision = task["revision"]!; + if (targetKind === "git_worktree") { + const checkout = join(campaignRoot, "checkouts", id); + const contents = await readFile(join(checkout, "src", "app.ts")); + manifest.scan.target.snapshotDigest = `codex-security-snapshot/v1:sha256:${createHash( + "sha256", + ) + .update(task["revision"]!) + .update("\0") + .update(contents) + .digest("hex")}`; + } else { + delete manifest.scan.target.snapshotDigest; + } + const scope = task["scope"]?.trim(); + let normalizedScope = scope ? posix.normalize(scope) : "."; + if (scope) { + const checkout = join(campaignRoot, "checkouts", id); + const canonicalScope = await realpath(join(checkout, scope)).catch( + () => undefined, + ); + if (canonicalScope !== undefined) { + normalizedScope = + relative(await realpath(checkout), canonicalScope) + .split(sep) + .join("/") || "."; + } + } + const includePaths = [normalizedScope]; + manifest.scan.scope.includePaths = includePaths; + coverage.includePaths = includePaths; + coverage.mode = scope + ? "scoped_path" + : task["mode"]?.trim() === "deep" + ? "deep_repository" + : "repository"; + coverage.inventoryStrategy = scope ? "scoped_path" : "repository"; + } + } + for (const finding of findings.findings) { + const fingerprint = `codex-security/v1:sha256:${createHash("sha256") + .update( + [ + "codex-security/v1", + manifest.scan.target.targetId, + finding.ruleId, + finding.identity.anchor, + finding.identity.instance ?? "", + ].join("\0"), + ) + .digest("hex")}`; + finding.fingerprints.primary = fingerprint; + finding.findingId = `csf_${createHash("sha256") + .update(fingerprint) + .digest("hex") + .slice(0, 24)}`; + finding.occurrenceId = `occ_${createHash("sha256") + .update([manifest.scan.id, fingerprint].join("\0")) + .digest("hex") + .slice(0, 24)}`; + } + coverage.completeness = completeness; + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + await writeFile(findingsPath, `${JSON.stringify(findings, null, 2)}\n`); + await writeFile(coveragePath, `${JSON.stringify(coverage, null, 2)}\n`); + await reseal(outputDir); + return { manifest, coverage: { completeness } } as ScanResult; +} + +async function reseal(outputDir: string): Promise { + const path = join(outputDir, "scan-manifest.json"); + const manifest = JSON.parse(await readFile(path, "utf8")) as { + scan: { artifacts: Array<{ path: string; sha256: string }> }; + }; + for (const artifact of manifest.scan.artifacts) { + artifact.sha256 = createHash("sha256") + .update(await readFile(join(outputDir, artifact.path))) + .digest("hex"); + } + await writeFile(path, `${JSON.stringify(manifest, null, 2)}\n`); } function client( @@ -351,35 +473,151 @@ describe("multiscan", () => { expect(invalid.text()).toContain("expected number to be >0"); }); - test("surfaces optional post-scan warnings without failing completed scans", async () => { + test("persists redacted scan warnings without failing completed scans", async () => { const paths = await fixture(); const source = await repository(paths.root, "follow-up-warning"); + const quiet = await repository(paths.root, "quiet"); + const secret = "sk-proj-SYNTHETIC_MULTISCAN_WARNING_123"; await writeFile( paths.input, - `id,repository,revision\nfollow-up-warning,${source.path},${source.revision}\n`, + [ + "id,repository,revision", + `follow-up-warning,${source.path},${source.revision}`, + `quiet,${quiet.path},${quiet.revision}`, + "", + ].join("\n"), ); const progress: Parameters< NonNullable >[0][] = []; - + let scans = 0; + const security = client(async (checkout, scanOptions = {}) => { + scans += 1; + if (basename(checkout) === "follow-up-warning") { + scanOptions.onWarning?.("Could not run post-scan instructions."); + scanOptions.onWarning?.(`Scan target changed after ${secret}.`); + } + return await completedScan(scanOptions.outputDir!); + }); const summary = await runMultiscan( - options( - paths, - client(async (_repository, scanOptions = {}) => { - scanOptions.onWarning?.("Could not run post-scan instructions."); - return await completedScan(scanOptions.outputDir!); - }), - { onProgress: (event) => progress.push(event) }, - ), + options(paths, security, { onProgress: (event) => progress.push(event) }), ); - expect(summary).toMatchObject({ completed: 1, incomplete: 0, failed: 0 }); + expect(summary).toMatchObject({ + completed: 2, + incomplete: 0, + failed: 0, + warned: 1, + }); expect(progress).toContainEqual({ repository: "follow-up-warning", attempt: 1, status: "started", warning: "Could not run post-scan instructions.", }); + const receipts = await results(summary.resultsPath); + expect(receipts).toMatchObject([ + { + id: "follow-up-warning", + status: "completed", + warnings: ["Could not run post-scan instructions.", "[redacted]"], + }, + { id: "quiet", status: "completed" }, + ]); + expect(receipts[1]).not.toHaveProperty("warnings"); + expect(await readFile(summary.resultsPath, "utf8")).not.toContain(secret); + + const ledger = await readFile(summary.resultsPath, "utf8"); + const unrelatedWarning = { + id: "OUTSIDE-CAMPAIGN", + warnings: ["Unrelated historical warning."], + }; + await appendFile( + summary.resultsPath, + `${JSON.stringify(unrelatedWarning)}\n`, + ); + const malformedLedger = await readFile(summary.resultsPath, "utf8"); + await expect(runMultiscan(options(paths, security))).rejects.toThrow( + "Multiscan recovery is required", + ); + expect(scans).toBe(2); + expect(await readFile(summary.resultsPath, "utf8")).toBe(malformedLedger); + await writeFile(summary.resultsPath, ledger); + await appendFile( + summary.resultsPath, + `${JSON.stringify({ + ...receipts[0], + ...unrelatedWarning, + })}\n`, + ); + for (const identity of [{ id: "QUIET" }, { repository: source.path }]) { + const previous = (await results(summary.resultsPath)).findLast( + (receipt) => receipt["id"] === "quiet", + ); + await appendFile( + summary.resultsPath, + `${JSON.stringify({ + ...previous, + ...identity, + warnings: ["Warning from another scan identity."], + })}\n`, + ); + expect(await runMultiscan(options(paths, security))).toMatchObject({ + completed: 2, + warned: 1, + skipped: 1, + }); + } + }); + + test("keeps warnings from failed attempts across retries and resumes", async () => { + const paths = await fixture(); + const source = await repository(paths.root, "warned-retry"); + await writeFile( + paths.input, + `id,repository,revision\nwarned-retry,${source.path},${source.revision}\n`, + ); + let attempts = 0; + const security = client(async (_repository, scanOptions = {}) => { + attempts += 1; + if (attempts === 1) { + scanOptions.onWarning?.("Scan target changed while it was running."); + throw new Error("temporary failure"); + } + return await completedScan(scanOptions.outputDir!); + }); + + const first = await runMultiscan( + options(paths, security, { maxAttempts: 1 }), + ); + expect(first).toMatchObject({ completed: 0, failed: 1, warned: 1 }); + + const retried = await runMultiscan( + options(paths, security, { maxAttempts: 1 }), + ); + expect(retried).toMatchObject({ + completed: 1, + failed: 0, + warned: 1, + skipped: 0, + }); + expect(await results(retried.resultsPath)).toMatchObject([ + { + status: "failed", + attempt: 1, + warnings: ["Scan target changed while it was running."], + }, + { status: "completed", attempt: 2 }, + ]); + + const resumed = await runMultiscan(options(paths, security)); + expect(resumed).toMatchObject({ + completed: 1, + failed: 0, + warned: 1, + skipped: 1, + }); + expect(attempts).toBe(2); }); test.each([false, true])( @@ -539,10 +777,6 @@ describe("multiscan", () => { ); const outputDir = join(paths.output, "artifacts", "legacy", "attempt-1"); await completedScan(outputDir, completeness); - await writeFile( - join(outputDir, "coverage.json"), - `${JSON.stringify({ completeness })}\n`, - ); const cost = { model: "gpt-5.6-sol", inputTokens: 1_250, @@ -693,7 +927,10 @@ describe("multiscan", () => { `id,repository,revision\nsample,${source.path},${source.revision}\n`, ); const outputDir = join(paths.output, "artifacts", "sample", "attempt-1"); - await completedScan(outputDir, completeness); + const completed = await completedScan(outputDir, completeness); + const result = fakeResult([], completeness); + result.manifest.scan.target.targetId = + completed.manifest.scan.target.targetId; const stdout = capture(); const stderr = capture(); let attempts = 0; @@ -708,7 +945,7 @@ describe("multiscan", () => { ]; const clientDependencies = dependencies({ currentDirectory: paths.root, - result: fakeResult([], completeness), + result, onRun: () => { attempts += 1; }, @@ -1023,7 +1260,9 @@ describe("multiscan", () => { await mkdir(checkout); const recovered = await runMultiscan(options(paths, security)); - expect(recovered).toMatchObject({ completed: 1, failed: 0, skipped: 0 }); + expect(recovered).toMatchObject({ completed: 1, failed: 0, skipped: 1 }); + expect(await results(recovered.resultsPath)).toEqual([receipt!]); + await access(join(receipt!["outputDir"] as string, "report.md")); expect(await readdir(join(paths.output, "checkouts"))).toEqual([]); await expect(access(lock)).rejects.toThrow(); }); @@ -1496,7 +1735,482 @@ describe("multiscan", () => { expect(ledger).not.toContain("SYNTHETIC"); }); - test("resumes complete bundles, repairs missing output, and rejects manifest drift", async () => { + test.each(["complete", "partial", "failed"] as const)( + "preserves the %s scan outcome when checkout cleanup fails", + async (outcome) => { + const failed = outcome === "failed"; + const expected = { + completed: outcome === "complete" ? 1 : 0, + incomplete: outcome === "partial" ? 1 : 0, + failed: failed ? 1 : 0, + warned: 1, + }; + const paths = await fixture(); + const source = await repository(paths.root, "cleanup-failure"); + await writeFile( + paths.input, + `id,repository,revision\ncleanup,${source.path},${source.revision}\n`, + ); + const checkout = join(paths.output, "checkouts", "cleanup"); + const originalRm = filesystem.rm; + let scanned = false; + const remove = spyOn(filesystem, "rm").mockImplementation( + async (...args: Parameters) => { + if (scanned && String(args[0]) === checkout) { + throw Object.assign(new Error("EACCES: checkout is in use"), { + code: "EACCES", + }); + } + return await originalRm(...args); + }, + ); + + try { + const summary = await runMultiscan( + options( + paths, + client(async (_repository, scanOptions = {}) => { + scanned = true; + if (outcome === "failed") { + throw new Error("Original scan failure."); + } + return await completedScan(scanOptions.outputDir!, outcome); + }), + { maxAttempts: 1 }, + ), + ); + + expect(summary).toMatchObject(expected); + expect(await results(summary.resultsPath)).toMatchObject([ + { + status: failed + ? "failed" + : outcome === "complete" + ? "completed" + : "completed_with_incomplete_coverage", + attempt: 1, + ...(failed ? { error: "Original scan failure." } : {}), + warnings: [ + "Multiscan checkout cleanup failed: EACCES: checkout is in use", + ], + }, + ]); + } finally { + remove.mockRestore(); + } + + if (!failed) { + const ledgerPath = join(paths.output, "results.jsonl"); + const ledger = await readFile(ledgerPath, "utf8"); + const resumed = await runMultiscan( + options( + paths, + client(async (_repository, scanOptions = {}) => + completedScan(scanOptions.outputDir!), + ), + ), + ); + expect(resumed).toMatchObject({ + ...expected, + skipped: 1, + }); + expect(await readdir(join(paths.output, "checkouts"))).toEqual([]); + expect(await readFile(ledgerPath, "utf8")).toBe(ledger); + } + }, + ); + + test("rescans corrupt, modified, and mismatched sealed repository artifacts", async () => { + const paths = await fixture(); + const source = await repository(paths.root, "resume-integrity"); + await writeFile( + paths.input, + `id,repository,revision\nresume-integrity,${source.path},${source.revision}\n`, + ); + let attempts = 0; + const security = client(async (_repository, scanOptions = {}) => { + attempts += 1; + return await completedScan(scanOptions.outputDir!); + }); + const first = await runMultiscan(options(paths, security)); + const foreignPaths = await fixture(); + await writeFile( + foreignPaths.input, + `id,repository,revision\nresume-integrity,${source.path},${source.revision}\n`, + ); + const foreign = await runMultiscan( + options( + foreignPaths, + client(async (_repository, scanOptions = {}) => + completedScan(scanOptions.outputDir!), + ), + ), + ); + const [foreignReceipt] = await results(foreign.resultsPath); + const [firstReceipt] = await results(first.resultsPath); + expect(foreignReceipt!["targetId"]).not.toBe(firstReceipt!["targetId"]); + + const modify = async ( + outputDir: string, + name: string, + update: ( + document: Record & { + scan?: ScanResult["manifest"]["scan"]; + }, + ) => void, + ): Promise => { + const path = join(outputDir, name); + const document = JSON.parse(await readFile(path, "utf8")) as Record< + string, + unknown + > & { scan?: ScanResult["manifest"]["scan"] }; + update(document); + await writeFile(path, `${JSON.stringify(document, null, 2)}\n`); + await reseal(outputDir); + }; + await modify( + firstReceipt!["outputDir"] as string, + "scan-manifest.json", + (manifest) => { + manifest.scan!.producer.version = "0.0.1"; + }, + ); + expect(await runMultiscan(options(paths, security))).toMatchObject({ + completed: 1, + skipped: 1, + }); + const corruptions: Array<(outputDir: string) => Promise> = [ + async (outputDir) => { + await writeFile( + join(outputDir, "scan-manifest.json"), + "{broken json\n", + ); + }, + async (outputDir) => { + await writeFile(join(outputDir, "findings.json"), "{}\n"); + await reseal(outputDir); + }, + async (outputDir) => { + await appendFile(join(outputDir, "coverage.json"), "\n"); + }, + (outputDir) => + modify(outputDir, "coverage.json", (coverage) => { + coverage["completeness"] = "partial"; + }), + (outputDir) => + modify(outputDir, "scan-manifest.json", (manifest) => { + manifest.scan!.target.revision = "0".repeat(40); + }), + (outputDir) => + modify(outputDir, "scan-manifest.json", (manifest) => { + manifest.scan!.target.displayName = "another-repository"; + }), + async (outputDir) => { + await cp(foreignReceipt!["outputDir"] as string, outputDir, { + recursive: true, + force: true, + }); + const contract = await loadContract(outputDir, { + pluginRoot: PLUGIN_ROOT, + }); + expect(contract.manifest.scan.target.targetId).toBe( + foreignReceipt!["targetId"] as string, + ); + }, + async (outputDir) => { + const receipts = await results(first.resultsPath); + receipts.at(-1)!["targetId"] = foreignReceipt!["targetId"]; + await writeFile( + first.resultsPath, + `${receipts.map((receipt) => JSON.stringify(receipt)).join("\n")}\n`, + ); + await loadContract(outputDir, { pluginRoot: PLUGIN_ROOT }); + }, + async (outputDir) => { + await modify(outputDir, "scan-manifest.json", (manifest) => { + manifest.scan!.producer.name = "another-security-plugin"; + }); + await loadContract(outputDir, { pluginRoot: PLUGIN_ROOT }); + }, + (outputDir) => + modify(outputDir, "scan-manifest.json", (manifest) => { + manifest.scan!.target.kind = "directory_snapshot"; + manifest.scan!.target.snapshotDigest = `codex-security-snapshot/v1:sha256:${"0".repeat(64)}`; + }), + (outputDir) => + modify(outputDir, "scan-manifest.json", (manifest) => { + manifest.scan!.target.snapshotDigest = `codex-security-snapshot/v1:sha256:${"0".repeat(64)}`; + }), + (outputDir) => + modify(outputDir, "coverage.json", (coverage) => { + coverage["mode"] = "deep_repository"; + }), + async (outputDir) => { + await modify(outputDir, "scan-manifest.json", (manifest) => { + manifest.scan!.scope.includePaths = ["another-scope"]; + }); + await modify(outputDir, "coverage.json", (coverage) => { + coverage["includePaths"] = ["another-scope"]; + }); + }, + async (outputDir) => { + await modify(outputDir, "scan-manifest.json", (manifest) => { + manifest.scan!.scope.excludePaths = ["src"]; + }); + await modify(outputDir, "coverage.json", (coverage) => { + coverage["excludePaths"] = ["src"]; + }); + await loadContract(outputDir, { pluginRoot: PLUGIN_ROOT }); + }, + ]; + + for (const corrupt of corruptions) { + const previous = join( + paths.output, + "artifacts", + "resume-integrity", + `attempt-${attempts}`, + ); + await corrupt(previous); + expect(await runMultiscan(options(paths, security))).toMatchObject({ + completed: 1, + failed: 0, + skipped: 0, + }); + await access(previous); + } + + expect(attempts).toBe(corruptions.length + 1); + expect(await results(join(paths.output, "results.jsonl"))).toHaveLength( + corruptions.length + 1, + ); + }); + + test.each(["complete", "partial"] as const)( + "binds sealed %s worktree snapshots to their validated attempt receipts", + async (completeness) => { + const paths = await fixture(); + const source = await repository(paths.root, "worktree-snapshot"); + await writeFile( + paths.input, + `id,repository,revision\nworktree,${source.path},${source.revision}\n`, + ); + let attempts = 0; + const security = client(async (_repository, scanOptions = {}) => { + attempts += 1; + return await completedScan( + scanOptions.outputDir!, + completeness, + "git_worktree", + ); + }); + const outcome = + completeness === "complete" + ? { completed: 1, incomplete: 0 } + : { completed: 0, incomplete: 1 }; + + const first = await runMultiscan(options(paths, security)); + const [originalReceipt] = await results(first.resultsPath); + const originalDigest = originalReceipt!["snapshotDigest"]; + const originalTargetId = originalReceipt!["targetId"]; + expect(originalDigest).toMatch( + /^codex-security-snapshot\/v1:sha256:[a-f\d]{64}$/, + ); + expect(originalTargetId).toMatch(/^target_sha256_[a-f\d]{64}$/); + expect(await runMultiscan(options(paths, security))).toMatchObject({ + ...outcome, + skipped: 1, + }); + expect(attempts).toBe(1); + + delete originalReceipt!["targetId"]; + await writeFile( + first.resultsPath, + `${JSON.stringify(originalReceipt)}\n`, + ); + expect(await runMultiscan(options(paths, security))).toMatchObject({ + ...outcome, + skipped: 1, + }); + expect(attempts).toBe(1); + + const manifestPath = join( + originalReceipt!["outputDir"] as string, + "scan-manifest.json", + ); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as { + scan: { target: { snapshotDigest: string } }; + }; + manifest.scan.target.snapshotDigest = `codex-security-snapshot/v1:sha256:${"0".repeat(64)}`; + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + await reseal(originalReceipt!["outputDir"] as string); + expect(await runMultiscan(options(paths, security))).toMatchObject({ + ...outcome, + skipped: 0, + }); + expect(attempts).toBe(2); + + const receipts = await results(first.resultsPath); + delete receipts.at(-1)!["snapshotDigest"]; + await writeFile( + first.resultsPath, + `${receipts.map((receipt) => JSON.stringify(receipt)).join("\n")}\n`, + ); + expect(await runMultiscan(options(paths, security))).toMatchObject({ + ...outcome, + skipped: 0, + }); + expect(await runMultiscan(options(paths, security))).toMatchObject({ + ...outcome, + skipped: 1, + }); + expect(attempts).toBe(3); + expect((await results(first.resultsPath)).at(-1)).toMatchObject({ + status: + completeness === "complete" + ? "completed" + : "completed_with_incomplete_coverage", + targetId: originalTargetId, + snapshotDigest: originalDigest, + }); + }, + ); + + test.each([ + ["scoped", "src", "standard"], + ["trailing-scope", "src/", "standard"], + ["root-scope", "./", "standard"], + ["deep", "", "deep"], + ] as const)( + "resumes current and legacy sealed %s scans matching the requested mode and scope", + async (id, scope, mode) => { + const paths = await fixture(); + const source = await repository(paths.root, id); + await writeFile( + paths.input, + `id,repository,revision,scope,mode\n${id},${source.path},${source.revision},${scope},${mode}\n`, + ); + let attempts = 0; + const security = client(async (_repository, scanOptions = {}) => { + attempts += 1; + return await completedScan(scanOptions.outputDir!); + }); + + const first = await runMultiscan(options(paths, security)); + expect(await runMultiscan(options(paths, security))).toMatchObject({ + completed: 1, + warned: 0, + skipped: 1, + }); + + const [legacy] = await results(first.resultsPath); + delete legacy!["targetId"]; + delete legacy!["resolvedScope"]; + const ledger = `${JSON.stringify(legacy)}\n`; + await writeFile(first.resultsPath, ledger); + expect(await runMultiscan(options(paths, security))).toMatchObject({ + completed: 1, + warned: 0, + skipped: 1, + }); + expect(await readFile(first.resultsPath, "utf8")).toBe(ledger); + expect(attempts).toBe(1); + }, + ); + + testPosix( + "resumes a sealed scope reached through an in-repository symlink", + async () => { + const paths = await fixture(); + const source = await repository(paths.root, "symlink-scope"); + await symlink("src", join(source.path, "alias"), "dir"); + git(source.path, "add", "alias"); + git( + source.path, + "-c", + "user.name=Multiscan Test", + "-c", + "user.email=multiscan@example.test", + "commit", + "-qm", + "add scoped directory alias", + ); + const revision = git(source.path, "rev-parse", "HEAD"); + await writeFile( + paths.input, + `id,repository,revision,scope\nsymlink-scope,${source.path},${revision},alias\n`, + ); + let attempts = 0; + const security = client(async (_repository, scanOptions = {}) => { + attempts += 1; + return await completedScan(scanOptions.outputDir!); + }); + + const first = await runMultiscan(options(paths, security)); + expect(await results(first.resultsPath)).toMatchObject([ + { status: "completed", scope: "alias", resolvedScope: "src" }, + ]); + expect(await runMultiscan(options(paths, security))).toMatchObject({ + completed: 1, + skipped: 1, + }); + expect(attempts).toBe(1); + + const [legacy] = await results(first.resultsPath); + delete legacy!["resolvedScope"]; + await writeFile(first.resultsPath, `${JSON.stringify(legacy)}\n`); + expect(await runMultiscan(options(paths, security))).toMatchObject({ + completed: 1, + skipped: 0, + }); + expect(await runMultiscan(options(paths, security))).toMatchObject({ + completed: 1, + skipped: 1, + }); + expect(attempts).toBe(2); + }, + ); + + test("validates resumed artifacts with a configured plugin archive", async () => { + const paths = await fixture(); + const source = await repository(paths.root, "custom-plugin"); + await writeFile( + paths.input, + `id,repository,revision\ncustom,${source.path},${source.revision}\n`, + ); + const pluginPath = join(paths.root, "plugin.zip"); + const entries: Record = {}; + for (const path of [ + ".codex-plugin/plugin.json", + "schemas/scan-manifest.schema.json", + "schemas/findings.schema.json", + "schemas/coverage.schema.json", + ]) { + entries[`release/${path}`] = await readFile(join(PLUGIN_ROOT, path)); + } + await writeFile(pluginPath, zipSync(entries)); + let attempts = 0; + const security = client(async (_repository, scanOptions = {}) => { + attempts += 1; + return await completedScan(scanOptions.outputDir!); + }); + const campaign = options(paths, security, { config: { pluginPath } }); + + await runMultiscan(campaign); + expect(await runMultiscan(campaign)).toMatchObject({ + completed: 1, + warned: 0, + skipped: 1, + }); + expect(attempts).toBe(1); + expect( + (await readdir(paths.output)).some((name) => + name.startsWith(".resume-plugin-"), + ), + ).toBe(false); + }); + + test("resumes complete bundles, repairs missing reports, and rejects manifest drift", async () => { const paths = await fixture(); const source = await repository(paths.root, "resume"); const csv = `id,repository,revision\nresume,${source.path},${source.revision}\n`; @@ -1524,19 +2238,122 @@ describe("multiscan", () => { expect(calls).toBe(1); const [receipt] = await results(initial.resultsPath); - await rm(join(receipt!["outputDir"] as string, "report.md")); + const outputDir = receipt!["outputDir"] as string; + const reportPath = join(outputDir, "report.md"); + const report = await readFile(reportPath); + const canonicalPaths = [ + "scan-manifest.json", + "findings.json", + "coverage.json", + ].map((name) => join(outputDir, name)); + const canonical = await Promise.all( + canonicalPaths.map((path) => readFile(path)), + ); + const ledger = await readFile(initial.resultsPath, "utf8"); + await rm(reportPath); const repaired = await runMultiscan(options(paths, security)); - expect(repaired).toMatchObject({ completed: 1, failed: 0, skipped: 0 }); - expect(calls).toBe(2); - expect((await results(repaired.resultsPath)).at(-1)?.["outputDir"]).toBe( - join(paths.output, "artifacts", "resume", "attempt-2"), - ); + expect(repaired).toMatchObject({ completed: 1, failed: 0, skipped: 1 }); + expect(calls).toBe(1); + expect(await readFile(reportPath)).toEqual(report); + expect( + await Promise.all(canonicalPaths.map((path) => readFile(path))), + ).toEqual(canonical); + expect(await readFile(repaired.resultsPath, "utf8")).toBe(ledger); await writeFile(paths.input, csv.replace("resume,", "changed,")); await expect(runMultiscan(options(paths, security))).rejects.toThrow( "manifest does not match", ); - expect(calls).toBe(2); + expect(calls).toBe(1); + }); + + test("skips sealed report recovery and preserves earned receipts on recovery failure", async () => { + const paths = await fixture(); + const source = await repository(paths.root, "report-recovery"); + await writeFile( + paths.input, + `id,repository,revision\nreport-recovery,${source.path},${source.revision}\n`, + ); + let attempts = 0; + const security = client(async (_repository, scanOptions = {}) => { + attempts += 1; + return await completedScan(scanOptions.outputDir!); + }); + const first = await runMultiscan(options(paths, security)); + const ledger = await readFile(first.resultsPath, "utf8"); + const resolvePython = spyOn( + runtime, + "resolvePluginPythonCommand", + ).mockRejectedValue( + new Error("Python unavailable: sk-proj-SYNTHETIC_REPORT_RECOVERY_123"), + ); + try { + const reportSealed = spyOn(contract, "hasSealedReport").mockResolvedValue( + true, + ); + try { + await expect( + runMultiscan(options(paths, security)), + ).resolves.toMatchObject({ completed: 1, failed: 0, skipped: 1 }); + expect(reportSealed).toHaveBeenCalledTimes(1); + expect(resolvePython).not.toHaveBeenCalled(); + expect(attempts).toBe(1); + expect(await readFile(first.resultsPath, "utf8")).toBe(ledger); + } finally { + reportSealed.mockRestore(); + } + await expect(runMultiscan(options(paths, security))).rejects.toThrow( + "Multiscan report recovery is required: [redacted]", + ); + expect(resolvePython).toHaveBeenCalledWith( + expect.objectContaining({ + additionalProtectedRoots: expect.arrayContaining([ + paths.output, + source.path, + await outermostGitMarkerRoot(await realpath(process.cwd())), + ]), + environment: runtime.pluginHelperEnvironment(process.env), + }), + ); + expect(attempts).toBe(1); + expect(await readFile(first.resultsPath, "utf8")).toBe(ledger); + } finally { + resolvePython.mockRestore(); + } + }); + + test("preserves cancellation during report recovery", async () => { + const paths = await fixture(); + const source = await repository(paths.root, "cancel-report-recovery"); + await writeFile( + paths.input, + `id,repository,revision\nreport-recovery,${source.path},${source.revision}\n`, + ); + let attempts = 0; + const security = client(async (_repository, scanOptions = {}) => { + attempts += 1; + return await completedScan(scanOptions.outputDir!); + }); + const first = await runMultiscan(options(paths, security)); + const ledger = await readFile(first.resultsPath, "utf8"); + const controller = new AbortController(); + const reason = new Error("Report recovery cancelled."); + const resolvePython = spyOn( + runtime, + "resolvePluginPythonCommand", + ).mockImplementation(async () => { + controller.abort(reason); + throw reason; + }); + try { + await expect( + runMultiscan(options(paths, security, { signal: controller.signal })), + ).rejects.toBe(reason); + expect(attempts).toBe(1); + expect(await readFile(first.resultsPath, "utf8")).toBe(ledger); + } finally { + resolvePython.mockRestore(); + } }); test("ignores repository-local Git shims while preserving credential configuration", async () => { diff --git a/sdk/typescript/tests-ts/plugin-report-limits.test.ts b/sdk/typescript/tests-ts/plugin-report-limits.test.ts index 1356466b..8458afe0 100644 --- a/sdk/typescript/tests-ts/plugin-report-limits.test.ts +++ b/sdk/typescript/tests-ts/plugin-report-limits.test.ts @@ -3,6 +3,82 @@ import { describe, expect, test } from "bun:test"; import { PLUGIN_ROOT } from "./plugin-root.js"; describe("bundled scan report and source limits", () => { + test("refreshes only unsealed report projections", () => { + const python = Bun.which("python3") ?? Bun.which("python"); + expect(python).not.toBeNull(); + const program = [ + "import json, os, pathlib, stat, sys", + "from contextlib import ExitStack", + "from unittest.mock import Mock, patch", + "sys.path.insert(0, sys.argv[1])", + "import finalize_scan_contract as finalizer", + "scan_dir, schema_dir = pathlib.Path('saved-scan'), pathlib.Path('selected-schemas')", + "findings, coverage = {}, {}", + "metadata = os.stat_result((stat.S_IFREG, 7, 11, 1, 0, 0, 0, 0, 0, 0))", + "directory = os.stat_result((stat.S_IFDIR, 1, 11, 1, 0, 0, 0, 0, 0, 0))", + "other_directory = os.stat_result((stat.S_IFDIR, 2, 11, 1, 0, 0, 0, 0, 0, 0))", + "def check_report(paths, entries, outcome, *, missing=False, separate_parent=False):", + " manifest = {'scan': {'artifacts': [{'path': path} for path in paths]}}", + " reader = Mock(return_value=(manifest, findings, coverage, b'canonical'))", + " project, writer = Mock(return_value=b'projected report\\n'), Mock()", + " opened, closed = Mock(return_value=42), Mock()", + " def inspect(path, *, follow_symlinks=True):", + " if path == scan_dir / 'report.md':", + " if missing: raise FileNotFoundError", + " return metadata", + " return other_directory if separate_parent and path == scan_dir / 'artifacts' else directory", + " with ExitStack() as stack:", + " for owner, name, replacement in [(finalizer, '_require_scan_directory', lambda path: path), (finalizer, '_read_sealed_scan', reader), (finalizer, '_generate_report_projection', project), (finalizer, 'write_scan_local_bytes', writer), (pathlib.Path, 'stat', inspect), (finalizer.os, 'listdir', lambda path: entries), (finalizer, 'open_scan_local_file_descriptor', opened), (finalizer.os, 'fstat', lambda descriptor: metadata), (finalizer.os, 'close', closed)]:", + " stack.enter_context(patch.object(owner, name, replacement))", + " if outcome == 'ambiguous':", + " try: finalizer.write_report_projection(scan_dir, schema_dir)", + " except finalizer.ContractError as exc: assert 'ambiguous sealed artifact alias' in str(exc)", + " else: raise AssertionError('ambiguous alias was accepted')", + " else: finalizer.write_report_projection(scan_dir, schema_dir)", + " reader.assert_called_once_with(scan_dir, schema_dir, 'report projection')", + " assert opened.call_count == closed.call_count", + " if outcome == 'write':", + " project.assert_called_once_with(manifest, findings, coverage)", + " writer.assert_called_once_with(scan_dir, 'report.md', b'projected report\\n')", + " else:", + " project.assert_not_called(); writer.assert_not_called()", + "check_report(['findings.json', 'coverage.json'], [], 'write', missing=True)", + "check_report(['./report.md'], [], 'preserve')", + "check_report(['REPORT.md'], ['REPORT.md'], 'preserve')", + "check_report(['REPORT.md'], ['report.md'], 'preserve')", + "check_report(['findings.json', 'coverage.json'], ['report.md', 'findings.json', 'coverage.json'], 'write')", + "check_report(['REPORT.md'], ['REPORT.md', 'report.md'], 'write')", + "check_report(['findings.json'], ['Report.md', 'findings.json'], 'write')", + "check_report(['artifacts/report.md'], ['report.md'], 'write', separate_parent=True)", + "check_report(['artifacts/report.md'], ['report.md'], 'ambiguous')", + "check_report(['REPORT.md'], [], 'ambiguous')", + "with patch.object(finalizer, 'write_report_projection') as report_only, patch.object(finalizer, 'finalize_scan') as full_finalizer, patch.object(finalizer, 'build_findings_export') as export, patch.object(finalizer, 'build_sarif_projection') as sarif, patch.object(sys, 'argv', ['finalizer', '--scan-dir', str(scan_dir), '--schema-dir', str(schema_dir), '--report-only']):", + " assert finalizer.main() == 0", + " report_only.assert_called_once_with(scan_dir, schema_dir)", + " full_finalizer.assert_not_called(); export.assert_not_called(); sarif.assert_not_called()", + "fingerprints = {'algorithm': finalizer.FINGERPRINT_ALGORITHM, 'primary': 'derived'}", + "finding = {'findingId': 'finding', 'occurrenceId': 'occurrence', 'fingerprints': {**fingerprints, 'future': 'preserved'}}", + "with patch.object(finalizer, '_derived_finding_identity_rows', return_value=[('finding', finding, 'finding', 'occurrence', fingerprints)]):", + " finalizer._validate_derived_finding_identities({}, {})", + " assert finding['fingerprints']['future'] == 'preserved'", + "print(json.dumps({'reportOnly': True, 'sealedReportPreserved': True, 'sealedAliasPreserved': True, 'distinctEntriesRegenerated': True, 'ambiguousAliasRejected': True, 'knownFingerprintsOnly': True}))", + ].join("\n"); + const result = Bun.spawnSync( + [python!, "-I", "-B", "-c", program, join(PLUGIN_ROOT, "scripts")], + { stdout: "pipe", stderr: "pipe" }, + ); + + expect(result.exitCode, new TextDecoder().decode(result.stderr)).toBe(0); + expect(JSON.parse(new TextDecoder().decode(result.stdout))).toEqual({ + reportOnly: true, + sealedReportPreserved: true, + sealedAliasPreserved: true, + distinctEntriesRegenerated: true, + ambiguousAliasRejected: true, + knownFingerprintsOnly: true, + }); + }); + test("accepts large reports, schemas, source files, and late source lines", () => { const python = Bun.which("python3") ?? Bun.which("python"); expect(python).not.toBeNull(); diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index 16280e70..8f0ab135 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -30,7 +30,7 @@ import { import { fileURLToPath, pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { brotliDecompressSync } from "node:zlib"; -import { afterEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; import { strToU8, zipSync } from "fflate"; import { BUNDLED_PLUGIN_VERSION, @@ -59,6 +59,7 @@ import { inspectWindowsCredentialAcl, inspectWindowsCredentialAclSnapshot, isPythonPathCandidate, + pluginHelperEnvironment, planOutputArchive, prepareCodexSecurityCredentialHome, preparePersistentScanRoot, @@ -68,11 +69,13 @@ import { requireSecureCredentialHome, requireSecureOutputAncestry, requireTrustedOutputAncestor, + resolvePluginPythonCommand, runWorkbench, setCodexSecurityCredentialLogout, streamWindowsCredentialAclDescriptors, verifyStableWindowsCredentialDescendants, } from "../src/runtime.js"; +import * as trustedExecutables from "../src/trusted-executable.js"; import { loadBundledRuntime, PLUGIN_ROOT } from "./plugin-root.js"; import { runMockInSubprocess } from "./support/isolated-mock.js"; @@ -4700,6 +4703,98 @@ describe("runtime directories and plugin Python boundary", () => { } }); + test("carries the accepted Python environment through every protected root", async () => { + if ( + runMockInSubprocess( + import.meta.path, + "carries the accepted Python environment through every protected root", + ) + ) { + return; + } + const available = Bun.which("python3") ?? Bun.which("python"); + expect(available).not.toBeNull(); + const interpreter = await realpath(available!); + const roots = ["invoking", "campaign", "source"].map((name) => + join(tmpdir(), `codex-security-${name}`), + ); + const environment = pluginHelperEnvironment({ + PATH: "initial-lookup", + KEEP: "preserved", + OPENAI_API_KEY: "synthetic-openai", + CODEX_API_KEY: "synthetic-codex", + OPENROUTER_API_KEY: "synthetic-openrouter", + FIREWORKS_API_KEY: "synthetic-fireworks", + ...(process.env["SystemRoot"] === undefined + ? {} + : { SystemRoot: process.env["SystemRoot"] }), + }); + const filtered = roots.map((_root, index) => ({ + ...environment, + PATH: + index === roots.length - 1 ? dirname(interpreter) : `filtered-${index}`, + })); + const calls: Array<{ + candidate: string; + environment: Readonly>; + root: string; + }> = []; + const resolveCommand = spyOn( + trustedExecutables, + "resolveTrustedExecutable", + ).mockImplementation(async (candidate, currentEnvironment, root) => { + calls.push({ candidate, environment: currentEnvironment, root }); + return { + executable: interpreter, + environment: filtered[calls.length - 1]!, + }; + }); + const selection = { + configuredPath: "python3", + environment, + protectedRoot: roots[0]!, + additionalProtectedRoots: roots.slice(1), + }; + try { + expect(await resolvePluginPythonCommand(selection)).toEqual({ + executable: interpreter, + environment: filtered.at(-1)!, + }); + expect(calls).toEqual( + roots.map((root, index) => ({ + candidate: "python3", + environment: index === 0 ? environment : filtered[index - 1]!, + root, + })), + ); + expect(environment).not.toHaveProperty("OPENAI_API_KEY"); + expect(environment).not.toHaveProperty("CODEX_API_KEY"); + expect(environment).not.toHaveProperty("OPENROUTER_API_KEY"); + expect(environment).not.toHaveProperty("FIREWORKS_API_KEY"); + + calls.length = 0; + resolveCommand.mockImplementation( + async (candidate, currentEnvironment, root) => { + calls.push({ candidate, environment: currentEnvironment, root }); + return root === roots.at(-1) + ? null + : { + executable: interpreter, + environment: filtered[calls.length - 1]!, + }; + }, + ); + await expect(resolvePluginPythonCommand(selection)).rejects.toThrow( + PluginPythonUnavailableError, + ); + expect(calls.map(({ candidate, root }) => ({ candidate, root }))).toEqual( + roots.map((root) => ({ candidate: "python3", root })), + ); + } finally { + resolveCommand.mockRestore(); + } + }); + test("resolves inherited Python names case-insensitively", async () => { const interpreter = Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py");