From 6b472115424888818facff2fac2470eed06e281d Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 16 Aug 2026 01:25:45 -0700 Subject: [PATCH 1/2] feat: check and resume Linear publication --- sdk/typescript/README.md | 33 ++- .../_bundled_plugin/scripts/workbench_cli.py | 6 +- .../_bundled_plugin/scripts/workbench_db.py | 51 ++++ sdk/typescript/scripts/smoke-package.mjs | 39 ++- sdk/typescript/src/cli.ts | 169 +++++++---- sdk/typescript/src/index.ts | 4 +- sdk/typescript/src/publication-store.ts | 115 ++++++-- sdk/typescript/src/publish.ts | 255 +++++++++++++--- sdk/typescript/tests-ts/cli-publish.test.ts | 144 ++++++++++ .../tests-ts/publication-check.test.ts | 272 ++++++++++++++++++ .../tests-ts/publication-integration.test.ts | 153 ++++++++++ .../tests-ts/publication-store.test.ts | 157 +++++++++- sdk/typescript/tests-ts/publish.test.ts | 205 +++++++++++++ 13 files changed, 1476 insertions(+), 127 deletions(-) create mode 100644 sdk/typescript/tests-ts/publication-check.test.ts diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 459a38b5..1bdadd04 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -610,6 +610,20 @@ added to successful publication results, scan history, or sealed scan artifacts. Error messages are preserved as returned. `--dry-run` never contacts Linear in either mode. +Use `publish check` to verify that the completed scan and its findings match +local history, and to see which findings already have recorded Linear issues: + +```bash +npx @openai/codex-security publish check /path/to/completed-scan \ + --to linear --linear-team TEAM_ID --json +``` + +The check does not create issues, migrate scan history, or change sealed scan +artifacts. With a Linear API key, it also makes read-only authentication, team, +optional project, and assignee checks. Without a key, connected-app access is +reported as `not-checked`. Issue-creation permission is always `not-tested`; +successful read access does not prove write permission. + Each finding creates a separate new issue titled `[Codex Security][HIGH] Finding title`. The issue includes the scan ID, repository, scanned scope, source locations and code snippets, severity, @@ -618,9 +632,18 @@ Verified immutable Git revisions include source links. Findings are published concurrently in batches of up to 20. Successful issue identifiers are linked to their findings in the local scan-history database, and structured results are read back from that database rather than generated by Codex. The completed -scan must already exist in the local scan history. Running publication again -creates another set of issues for the same scan; existing issues are not -matched, updated, or reused. +scan must already exist in the local scan history. By default, running +publication again creates another set of issues for the same scan. Add +`--skip-existing` to skip findings with a recorded issue for the exact scan +occurrence, team, and optional project. Combine it with `--dry-run` to preview +only the remaining findings. Results distinguish newly `created` issues from +previously recorded `skipped` issues. + +This option uses local publication history; it does not search, update, or +verify the continued existence of remote issues. Recover any retained handoff +from an interrupted or uncertain publication before retrying. Concurrent +publishers and remote creations that were never recorded can still create +duplicates. Issue descriptions contain source code and vulnerability details. Select a Linear destination authorized to receive that information. Publication receipts @@ -648,7 +671,9 @@ console.log(publication.created.length); ``` Add `projectId: "PROJECT_ID"` to the options to publish into a specific Linear -project instead of directly to the team. +project instead of directly to the team. Pass `skipExisting: true` to skip +recorded successes, or import `checkScanPublication` and call it with the same +destination options for a read-only preflight. Pass `linearApiKey` to publish directly through the Linear API. Omit `assigneeId` to leave issues unassigned, or supply a Linear user ID or email diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_cli.py b/sdk/typescript/_bundled_plugin/scripts/workbench_cli.py index 77e9d56a..941838dd 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_cli.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_cli.py @@ -304,7 +304,11 @@ def parse_args(description: str) -> argparse.Namespace: export_findings.add_argument("--scan-id", required=True) export_findings.add_argument("--format", choices=EXPORT_FORMATS, required=True) - for command in ("prepare-linear-publication", "record-linear-publications"): + for command in ( + "inspect-linear-publication", + "prepare-linear-publication", + "record-linear-publications", + ): publication = subparsers.add_parser(command) publication.add_argument("--input-file", required=True) diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py index 5584d3a1..90293177 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py @@ -2507,6 +2507,53 @@ def verify_linear_publication_scan( return scan +def inspect_linear_publication(args: argparse.Namespace) -> dict[str, Any]: + payload, destination, findings = linear_publication_input(args, recording=False) + with closing( + sqlite3.connect(f"{database_path().as_uri()}?mode=ro", uri=True, timeout=5) + ) as connection: + connection.row_factory = sqlite3.Row + connection.execute("BEGIN") + scan = verify_linear_publication_scan(connection, payload, findings) + recorded: dict[str, dict[str, str]] = {} + if connection.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'finding_publications'" + ).fetchone(): + for row in connection.execute( + """ + SELECT finding_id, occurrence_id, external_id, external_url + FROM finding_publications + WHERE scan_id = ? AND destination_type = ? AND team_id = ? AND project_id IS ? + ORDER BY created_at, external_id + """, + ( + scan["id"], + destination["type"], + destination["teamId"], + destination.get("projectId"), + ), + ): + recorded.setdefault( + row["occurrence_id"], + { + "findingId": row["finding_id"], + "occurrenceId": row["occurrence_id"], + "issueIdentifier": row["external_id"], + **({"url": row["external_url"]} if row["external_url"] is not None else {}), + }, + ) + return { + "scanId": scan["id"], + "destination": destination, + "findingCount": len(findings), + "recorded": [ + recorded[finding["occurrenceId"]] + for finding in findings + if finding["occurrenceId"] in recorded + ], + } + + def prepare_linear_publication( connection: sqlite3.Connection, args: argparse.Namespace ) -> dict[str, Any]: @@ -3844,6 +3891,10 @@ def main() -> None: result = inspect_setup(args) print(json.dumps(result, allow_nan=False, sort_keys=True)) return + if args.command == "inspect-linear-publication": + result = inspect_linear_publication(args) + print(json.dumps(result, allow_nan=False, sort_keys=True)) + return with closing(connect()) as connection: if args.command == "create-workspace": result = create_workspace(connection, args) diff --git a/sdk/typescript/scripts/smoke-package.mjs b/sdk/typescript/scripts/smoke-package.mjs index 9c7307b6..344355eb 100644 --- a/sdk/typescript/scripts/smoke-package.mjs +++ b/sdk/typescript/scripts/smoke-package.mjs @@ -346,7 +346,7 @@ try { [ "--input-type=module", "--eval", - `const sdk = await import(${JSON.stringify(packageManifest.name)}); if (typeof sdk.CodexSecurity !== "function") throw new Error("The installed package does not export CodexSecurity."); if (typeof sdk.publishScan !== "function") throw new Error("The installed package does not export publishScan.");`, + `const sdk = await import(${JSON.stringify(packageManifest.name)}); for (const name of ["CodexSecurity", "publishScan", "checkScanPublication"]) if (typeof sdk[name] !== "function") throw new Error("The installed package does not export " + name + ".");`, ], { cwd: consumer }, ); @@ -445,6 +445,43 @@ try { assert.equal(publication.counts.findings, 1); assert.equal(publication.counts.created, 0); assert.match(publication.issues[0].title, /^\[Codex Security\]\[HIGH\] /u); + assert.match( + run(process.execPath, [launcher, "publish", "scan", "--help"], { + cwd: consumer, + capture: true, + }), + /--skip-existing/u, + ); + const missingHistory = spawnSync( + process.execPath, + [ + launcher, + "publish", + "check", + publicationScan, + "--to", + "linear", + "--linear-team", + "team-example", + "--json", + ], + { + cwd: consumer, + encoding: "utf8", + env: { + ...process.env, + CODEX_SECURITY_LINEAR_API_KEY: "", + CODEX_SECURITY_STATE_DIR: join(consumer, "publication-state"), + }, + timeout: PACKAGE_SMOKE_TIMEOUT_MS, + windowsHide: true, + }, + ); + assert.equal(missingHistory.status, 2, missingHistory.stderr); + assert.match(missingHistory.stderr, /scan-history database does not exist/u); + await assert.rejects(stat(join(consumer, "publication-state")), { + code: "ENOENT", + }); const networkGuard = join(consumer, "reject-publication-network.cjs"); await writeFile( diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index d660551a..196fde0d 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -84,7 +84,9 @@ import { import type { SeverityLevel } from "./models.js"; import { runMultiscan } from "./multiscan.js"; import { + checkScanPublication, publishScan, + type CheckScanPublicationOptions, type PublishScanProgress, type PublishScanResult, } from "./publish.js"; @@ -236,6 +238,72 @@ function optionValue(flag: string) { return z.string().min(1, `${flag} must not be empty.`); } +const PUBLICATION_DESTINATION_OPTIONS = z.object({ + to: z.literal("linear").describe("Publication destination."), + linearTeam: optionValue("--linear-team") + .optional() + .describe("Linear team ID; defaults to CODEX_SECURITY_LINEAR_TEAM."), + linearApiKey: optionValue("--linear-api-key") + .optional() + .describe( + "Linear personal API key; defaults to CODEX_SECURITY_LINEAR_API_KEY.", + ), + project: optionValue("--project") + .optional() + .describe( + "Optional Linear project ID; defaults to CODEX_SECURITY_LINEAR_PROJECT.", + ), + linearAssignee: optionValue("--linear-assignee") + .optional() + .describe( + "Linear assignee email or user ID; omit to leave issues unassigned.", + ), +}); + +function publicationDestination( + options: z.infer, + environment: NodeJS.ProcessEnv, +): CheckScanPublicationOptions { + const selectedApiKey = + options.linearApiKey ?? environment["CODEX_SECURITY_LINEAR_API_KEY"]; + const linearApiKey = selectedApiKey?.trim() || undefined; + if (options.linearApiKey !== undefined && linearApiKey === undefined) { + throw new CodexSecurityError("--linear-api-key must not be empty."); + } + const assigneeId = options.linearAssignee?.trim(); + if (options.linearAssignee !== undefined && !assigneeId) { + throw new CodexSecurityError("--linear-assignee must not be empty."); + } + if (assigneeId !== undefined && linearApiKey === undefined) { + throw new CodexSecurityError( + "--linear-assignee requires --linear-api-key or CODEX_SECURITY_LINEAR_API_KEY.", + ); + } + const teamId = + options.linearTeam?.trim() || + environment["CODEX_SECURITY_LINEAR_TEAM"]?.trim(); + if (!teamId) { + throw new CodexSecurityError( + "--linear-team or CODEX_SECURITY_LINEAR_TEAM is required.", + ); + } + const selectedProject = options.project?.trim(); + if (options.project !== undefined && !selectedProject) { + throw new CodexSecurityError("--project must not be empty."); + } + const projectId = + selectedProject || + environment["CODEX_SECURITY_LINEAR_PROJECT"]?.trim() || + undefined; + return { + destination: options.to, + teamId, + ...(projectId === undefined ? {} : { projectId }), + ...(linearApiKey === undefined ? {} : { linearApiKey }), + ...(assigneeId === undefined ? {} : { assigneeId }), + }; +} + function publicationScanAge(timestamp: string, now: number): string { const completedAt = Date.parse(timestamp); if (!Number.isFinite(completedAt)) return "unknown"; @@ -347,6 +415,12 @@ function renderPublicationSummary( `${created} total issue${created === 1 ? "" : "s"} created`, `${failed} total issue${failed === 1 ? "" : "s"} failed`, ); + if (result.skipped !== undefined) { + const skipped = result.skipped.length; + lines.push( + `${skipped} previously recorded issue${skipped === 1 ? "" : "s"} skipped`, + ); + } return `${lines.join("\n")}\n`; } @@ -696,6 +770,7 @@ interface CliDependencies { hasStoredChatGPTSignIn?: () => Promise; scanAuthenticationPrompt?: Pick; publishPrompt?: Pick; + checkScanPublication?: typeof checkScanPublication; publishScan?: typeof publishScan; currentDirectory(): string; now(): number; @@ -1535,30 +1610,17 @@ export async function main( .optional() .describe("Completed scan directory; omit to select a saved scan."), }), - options: z.object({ - to: z.literal("linear").describe("Publication destination."), - linearTeam: optionValue("--linear-team") - .optional() - .describe("Linear team ID; defaults to CODEX_SECURITY_LINEAR_TEAM."), - linearApiKey: optionValue("--linear-api-key") - .optional() - .describe( - "Linear personal API key; defaults to CODEX_SECURITY_LINEAR_API_KEY.", - ), - project: optionValue("--project") - .optional() - .describe( - "Optional Linear project ID; defaults to CODEX_SECURITY_LINEAR_PROJECT.", - ), - linearAssignee: optionValue("--linear-assignee") - .optional() - .describe( - "Linear assignee email or user ID; omit to leave issues unassigned.", - ), + options: PUBLICATION_DESTINATION_OPTIONS.extend({ dryRun: z .boolean() .default(false) .describe("Preview the findings without creating Linear issues."), + skipExisting: z + .boolean() + .default(false) + .describe( + "Skip findings already recorded for this exact Linear destination.", + ), }), output: z.record(z.string(), z.unknown()).optional(), async run({ args, format, formatExplicit, options }) { @@ -1572,38 +1634,10 @@ export async function main( const onTerminate = (): void => cancel("SIGTERM"); let observingSignals = false; try { - const selectedApiKey = - options.linearApiKey ?? - dependencies.environment["CODEX_SECURITY_LINEAR_API_KEY"]; - const linearApiKey = selectedApiKey?.trim() || undefined; - if (options.linearApiKey !== undefined && linearApiKey === undefined) { - throw new CodexSecurityError("--linear-api-key must not be empty."); - } - const assigneeId = options.linearAssignee?.trim(); - if (options.linearAssignee !== undefined && !assigneeId) { - throw new CodexSecurityError("--linear-assignee must not be empty."); - } - if (assigneeId !== undefined && linearApiKey === undefined) { - throw new CodexSecurityError( - "--linear-assignee requires --linear-api-key or CODEX_SECURITY_LINEAR_API_KEY.", - ); - } - const teamId = - options.linearTeam?.trim() || - dependencies.environment["CODEX_SECURITY_LINEAR_TEAM"]?.trim(); - if (!teamId) { - throw new CodexSecurityError( - "--linear-team or CODEX_SECURITY_LINEAR_TEAM is required.", - ); - } - const selectedProject = options.project?.trim(); - if (options.project !== undefined && !selectedProject) { - throw new CodexSecurityError("--project must not be empty."); - } - const projectId = - selectedProject || - dependencies.environment["CODEX_SECURITY_LINEAR_PROJECT"]?.trim() || - undefined; + const destination = publicationDestination( + options, + dependencies.environment, + ); let scanDir = args.scanDir; let publicationRepository = @@ -1782,12 +1816,9 @@ export async function main( result = await (dependencies.publishScan ?? publishScan)( resolve(dependencies.currentDirectory(), scanDir), { - destination: options.to, - teamId, - ...(projectId === undefined ? {} : { projectId }), + ...destination, dryRun: options.dryRun, - ...(linearApiKey === undefined ? {} : { linearApiKey }), - ...(assigneeId === undefined ? {} : { assigneeId }), + ...(options.skipExisting ? { skipExisting: true } : {}), ...(options.dryRun ? {} : { @@ -1850,6 +1881,30 @@ export async function main( } }, }); + publication.command("check", { + description: + "Check saved scan history and Linear access without creating issues.", + mcp: false, + args: z.object({ + scanDir: z.string().describe("Completed scan directory."), + }), + options: PUBLICATION_DESTINATION_OPTIONS, + output: z.record(z.string(), z.unknown()).optional(), + async run({ args, options }) { + try { + return { + ...(await (dependencies.checkScanPublication ?? checkScanPublication)( + resolve(dependencies.currentDirectory(), args.scanDir), + publicationDestination(options, dependencies.environment), + )), + }; + } catch (error) { + errorOutput.write(`codex-security: ${errorMessage(error)}\n`); + exitCode = 2; + return undefined; + } + }, + }); const cli = Cli.create("codex-security", { description: "Run, validate, patch, export, and publish Codex Security findings.", diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index f676f3ac..f7d5973c 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -46,8 +46,10 @@ export type { CodexSecurityConfig, JsonObject, JsonValue } from "./config.js"; export { loadContract, requireScanFile } from "./contract.js"; export type { LoadedContract, ScanExpectation } from "./contract.js"; export type * from "./models.js"; -export { publishScan } from "./publish.js"; +export { checkScanPublication, publishScan } from "./publish.js"; export type { + CheckScanPublicationOptions, + CheckScanPublicationResult, PublishScanOptions, PublishScanProgress, PublishScanResult, diff --git a/sdk/typescript/src/publication-store.ts b/sdk/typescript/src/publication-store.ts index 866f5e06..f32b97de 100644 --- a/sdk/typescript/src/publication-store.ts +++ b/sdk/typescript/src/publication-store.ts @@ -1,4 +1,5 @@ import { mkdtemp, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { CodexSecurityError } from "./errors.js"; import type { PreparedScanPublication } from "./publication.js"; @@ -10,6 +11,43 @@ import { runWorkbench, } from "./runtime.js"; +export async function inspectPublicationStore( + publication: PreparedScanPublication, + environment: NodeJS.ProcessEnv, +): Promise { + const result = await runPublicationWorkbench( + "inspect-linear-publication", + publication, + environment, + ); + const recorded = result["recorded"]; + if ( + !matchesPublication(result, publication) || + result["findingCount"] !== publication.issues.length || + !Array.isArray(recorded) + ) { + throw invalidPublicationRecords(); + } + const expected = new Map( + publication.issues.map((issue) => [issue.findingId, issue.occurrenceId]), + ); + const found = new Map(); + for (const value of recorded) { + const issue = readPublicationRecord(value); + if ( + expected.get(issue.findingId) !== issue.occurrenceId || + found.has(issue.findingId) + ) { + throw invalidPublicationRecords(); + } + found.set(issue.findingId, issue); + } + return publication.issues.flatMap(({ findingId }) => { + const issue = found.get(findingId); + return issue === undefined ? [] : [issue]; + }); +} + export async function preparePublicationStore( publication: PreparedScanPublication, environment: NodeJS.ProcessEnv, @@ -41,13 +79,8 @@ export async function recordPublishedIssues( issues, ); const created = result["created"]; - const destination = result["destination"]; if ( - result["scanId"] !== publication.scanId || - !isRecord(destination) || - destination["type"] !== publication.destination.type || - destination["teamId"] !== publication.destination.teamId || - destination["projectId"] !== publication.destination.projectId || + !matchesPublication(result, publication) || !Array.isArray(created) || created.length !== issues.length ) { @@ -65,29 +98,25 @@ export async function recordPublishedIssues( return created.map((value, index) => { const expectedIssue = ordered[index]; + const issue = readPublicationRecord(value); if ( - !isRecord(value) || expectedIssue === undefined || - value["findingId"] !== expectedIssue.findingId || - value["occurrenceId"] !== expectedIssue.occurrenceId || - value["issueIdentifier"] !== expectedIssue.issueIdentifier || - (value["url"] !== undefined && typeof value["url"] !== "string") || - (expectedIssue.url !== undefined && value["url"] !== expectedIssue.url) + issue.findingId !== expectedIssue.findingId || + issue.occurrenceId !== expectedIssue.occurrenceId || + issue.issueIdentifier !== expectedIssue.issueIdentifier || + (expectedIssue.url !== undefined && issue.url !== expectedIssue.url) ) { throw invalidPublicationRecords(); } - - return { - findingId: value["findingId"] as string, - occurrenceId: value["occurrenceId"] as string, - issueIdentifier: value["issueIdentifier"] as string, - ...(typeof value["url"] === "string" ? { url: value["url"] } : {}), - }; + return issue; }); } async function runPublicationWorkbench( - command: "prepare-linear-publication" | "record-linear-publications", + command: + | "inspect-linear-publication" + | "prepare-linear-publication" + | "record-linear-publications", publication: PreparedScanPublication, environment: NodeJS.ProcessEnv, issues?: readonly PublishedScanIssue[], @@ -113,7 +142,12 @@ async function runPublicationWorkbench( findingId, occurrenceId, })); - const directory = await mkdtemp(join(stateDirectory, "publication-")); + const directory = await mkdtemp( + join( + command === "inspect-linear-publication" ? tmpdir() : stateDirectory, + "publication-", + ), + ); try { const input = join(directory, "publication.json"); await writeFile( @@ -133,9 +167,9 @@ async function runPublicationWorkbench( pluginRoot, environment, failureMessage: - command === "prepare-linear-publication" - ? "Cannot publish findings without their existing local Codex Security scan history" - : "Could not persist created Linear issues in the local Codex Security scan history", + command === "record-linear-publications" + ? "Could not persist created Linear issues in the local Codex Security scan history" + : "Cannot publish findings without their existing local Codex Security scan history", }, [command, "--input-file", input], ); @@ -146,6 +180,39 @@ async function runPublicationWorkbench( } } +function matchesPublication( + result: Record, + publication: PreparedScanPublication, +): boolean { + const destination = result["destination"]; + return ( + result["scanId"] === publication.scanId && + isRecord(destination) && + destination["type"] === publication.destination.type && + destination["teamId"] === publication.destination.teamId && + destination["projectId"] === publication.destination.projectId + ); +} + +function readPublicationRecord(value: unknown): PublishedScanIssue { + if ( + !isRecord(value) || + typeof value["findingId"] !== "string" || + typeof value["occurrenceId"] !== "string" || + typeof value["issueIdentifier"] !== "string" || + !value["issueIdentifier"].trim() || + (value["url"] !== undefined && typeof value["url"] !== "string") + ) { + throw invalidPublicationRecords(); + } + return { + findingId: value["findingId"], + occurrenceId: value["occurrenceId"], + issueIdentifier: value["issueIdentifier"], + ...(typeof value["url"] === "string" ? { url: value["url"] } : {}), + }; +} + function invalidPublicationRecords(): CodexSecurityError { return new CodexSecurityError( "The workbench returned invalid persisted Linear publication records.", diff --git a/sdk/typescript/src/publish.ts b/sdk/typescript/src/publish.ts index 266de10f..f5357801 100644 --- a/sdk/typescript/src/publish.ts +++ b/sdk/typescript/src/publish.ts @@ -26,6 +26,7 @@ import { matchPublicationIssue, } from "./publication-events.js"; import { + inspectPublicationStore, preparePublicationStore, recordPublishedIssues, } from "./publication-store.js"; @@ -42,6 +43,7 @@ export interface PublishScanOptions { linearApiKey?: string; assigneeId?: string; dryRun?: boolean; + skipExisting?: boolean; signal?: AbortSignal; onProgress?: (event: PublishScanProgress) => void; } @@ -77,16 +79,52 @@ export interface PublishScanResult { destination: LinearPublicationDestination; created: PublishedScanIssue[]; failed: FailedScanPublication[]; + skipped?: PublishedScanIssue[]; counts: { findings: number; created: number; failed: number; + skipped?: number; }; dryRun?: boolean; issues?: PreparedPublicationIssue[]; warnings?: string[]; } +export type CheckScanPublicationOptions = Pick< + PublishScanOptions, + | "destination" + | "teamId" + | "projectId" + | "linearApiKey" + | "assigneeId" + | "signal" +>; + +export interface CheckScanPublicationResult { + scanId: string; + destination: LinearPublicationDestination; + recorded: PublishedScanIssue[]; + counts: { findings: number; recorded: number; pending: number }; + access: { + transport: "linear-api" | "connected-app"; + authentication: "verified" | "not-checked"; + team: "verified" | "not-checked"; + project: "verified" | "not-checked" | "not-requested"; + assignee: "verified" | "not-checked" | "not-requested"; + issueCreation: "not-tested"; + }; +} + +export interface CheckScanPublicationDependencies { + environment?: NodeJS.ProcessEnv; + prepare?: typeof prepareScanPublication; + inspectPublicationStore?: typeof inspectPublicationStore; + linearClient?: ( + options: ConstructorParameters[0], + ) => Pick; +} + export interface PublicationCodexResult { exitCode: number; stdout: string; @@ -108,6 +146,7 @@ export interface PublishScanDependencies { onEvent?: (event: unknown) => void, signal?: AbortSignal, ) => Promise; + inspectPublicationStore?: typeof inspectPublicationStore; preparePublicationStore?: typeof preparePublicationStore; recordPublishedIssues?: typeof recordPublishedIssues; writeReceipt?: ( @@ -129,33 +168,14 @@ export async function publishScanInternal( dependencies: PublishScanDependencies = {}, ): Promise { options.signal?.throwIfAborted(); - if (options.destination !== "linear") { - throw new ConfigurationError("The publication destination must be linear."); - } - if (!options.teamId.trim()) { - throw new ConfigurationError("A Linear team is required for publication."); - } - if (options.projectId !== undefined && !options.projectId.trim()) { - throw new ConfigurationError( - "A Linear project cannot be blank when provided.", - ); - } - const environment = dependencies.environment ?? process.env; - const linearApiKey = - options.linearApiKey?.trim() || - environment["CODEX_SECURITY_LINEAR_API_KEY"]?.trim() || - undefined; - if (options.assigneeId !== undefined && linearApiKey === undefined) { - throw new ConfigurationError( - "A Linear API key is required to select a publication assignee.", - ); - } + const linearApiKey = publicationApiKey(options, environment); - const prepared = await (dependencies.prepare ?? prepareScanPublication)( + const preparedScan = await (dependencies.prepare ?? prepareScanPublication)( scanDirectory, options, ); + let prepared = preparedScan; options.signal?.throwIfAborted(); const result: PublishScanResult = { scanId: prepared.scanId, @@ -169,13 +189,27 @@ export async function publishScanInternal( failed: 0, }, }; + if (options.skipExisting) { + result.skipped = await ( + dependencies.inspectPublicationStore ?? inspectPublicationStore + )(preparedScan, environment); + result.counts.skipped = result.skipped.length; + const recorded = new Set(result.skipped.map((issue) => issue.findingId)); + prepared = { + ...preparedScan, + issues: preparedScan.issues.filter( + (issue) => !recorded.has(issue.findingId), + ), + }; + options.signal?.throwIfAborted(); + } if (options.dryRun) { return { ...result, dryRun: true, issues: prepared.issues }; } if (prepared.issues.length === 0) return result; await (dependencies.preparePublicationStore ?? preparePublicationStore)( - prepared, + preparedScan, environment, ); options.signal?.throwIfAborted(); @@ -190,19 +224,10 @@ export async function publishScanInternal( redirect: "error", ...(options.signal === undefined ? {} : { signal: options.signal }), }); - let assigneeId = options.assigneeId; - if (linearClient !== undefined && assigneeId?.includes("@")) { - const users = await linearClient.users({ - filter: { email: { eqIgnoreCase: assigneeId } }, - first: 2, - }); - if (users.nodes.length !== 1) { - throw new ConfigurationError( - "Linear could not resolve exactly one matching issue assignee.", - ); - } - assigneeId = users.nodes[0]!.id; - } + const assigneeId = + linearClient === undefined || options.assigneeId === undefined + ? options.assigneeId + : await resolvePublicationAssignee(linearClient, options.assigneeId); const command = linearClient === undefined ? (dependencies.resolveCodex ?? resolveCodexCommand)(environment) @@ -308,7 +333,7 @@ export async function publishScanInternal( try { result.created = await ( dependencies.recordPublishedIssues ?? recordPublishedIssues - )(prepared, handoffResults.created, environment); + )(preparedScan, handoffResults.created, environment); } catch (error) { const detail = error instanceof Error ? error.message : String(error); throw new CodexSecurityError( @@ -373,11 +398,165 @@ export async function publishScanInternal( type: "completed", created: result.counts.created, failed: result.counts.failed, - total: result.counts.findings, + total: prepared.issues.length, + }); + return result; +} + +export async function checkScanPublication( + scanDirectory: string, + options: CheckScanPublicationOptions, +): Promise { + return checkScanPublicationInternal(scanDirectory, options); +} + +export async function checkScanPublicationInternal( + scanDirectory: string, + options: CheckScanPublicationOptions, + dependencies: CheckScanPublicationDependencies = {}, +): Promise { + options.signal?.throwIfAborted(); + const environment = dependencies.environment ?? process.env; + const linearApiKey = publicationApiKey(options, environment); + const prepared = await (dependencies.prepare ?? prepareScanPublication)( + scanDirectory, + options, + ); + const recorded = await ( + dependencies.inspectPublicationStore ?? inspectPublicationStore + )(prepared, environment); + options.signal?.throwIfAborted(); + const result: CheckScanPublicationResult = { + scanId: prepared.scanId, + destination: prepared.destination, + recorded, + counts: { + findings: prepared.issues.length, + recorded: recorded.length, + pending: prepared.issues.length - recorded.length, + }, + access: { + transport: linearApiKey === undefined ? "connected-app" : "linear-api", + authentication: "not-checked", + team: "not-checked", + project: + options.projectId === undefined ? "not-requested" : "not-checked", + assignee: + options.assigneeId === undefined ? "not-requested" : "not-checked", + issueCreation: "not-tested", + }, + }; + if (linearApiKey === undefined) return result; + + const client = ( + dependencies.linearClient ?? + ((configuration) => new LinearClient(configuration)) + )({ + apiKey: linearApiKey, + redirect: "error", + ...(options.signal === undefined ? {} : { signal: options.signal }), }); + let step = "authentication"; + try { + await client.viewer; + result.access.authentication = "verified"; + step = "team access"; + const team = await client.team(prepared.destination.teamId); + if (team.archivedAt || team.retiredAt) { + throw new ConfigurationError( + "The selected Linear team is archived or retired.", + ); + } + result.access.team = "verified"; + if (prepared.destination.projectId !== undefined) { + step = "project access"; + const project = await client.project(prepared.destination.projectId); + if (project.archivedAt) { + throw new ConfigurationError( + "The selected Linear project is archived.", + ); + } + const teams = await project.teams({ + filter: { id: { eq: team.id } }, + first: 1, + }); + if (!teams.nodes.some(({ id }) => id === team.id)) { + throw new ConfigurationError( + "The selected Linear project does not belong to the selected team.", + ); + } + result.access.project = "verified"; + } + if (options.assigneeId !== undefined) { + step = "assignee access"; + const assigneeId = await resolvePublicationAssignee( + client, + options.assigneeId, + ); + const assignee = await client.user(assigneeId); + if (!assignee.active) { + throw new ConfigurationError( + "The selected Linear assignee is inactive.", + ); + } + result.access.assignee = "verified"; + } + } catch (error) { + options.signal?.throwIfAborted(); + if (error instanceof ConfigurationError) throw error; + throw new CodexSecurityError( + `Could not verify Linear ${step}. Check the API key and publication destination.`, + { cause: error }, + ); + } + options.signal?.throwIfAborted(); return result; } +function publicationApiKey( + options: CheckScanPublicationOptions, + environment: NodeJS.ProcessEnv, +): string | undefined { + if (options.destination !== "linear") { + throw new ConfigurationError("The publication destination must be linear."); + } + if (!options.teamId.trim()) { + throw new ConfigurationError("A Linear team is required for publication."); + } + if (options.projectId !== undefined && !options.projectId.trim()) { + throw new ConfigurationError( + "A Linear project cannot be blank when provided.", + ); + } + const linearApiKey = + options.linearApiKey?.trim() || + environment["CODEX_SECURITY_LINEAR_API_KEY"]?.trim() || + undefined; + if (options.assigneeId !== undefined && linearApiKey === undefined) { + throw new ConfigurationError( + "A Linear API key is required to select a publication assignee.", + ); + } + return linearApiKey; +} + +async function resolvePublicationAssignee( + client: Pick, + assigneeId: string, +): Promise { + if (!assigneeId.includes("@")) return assigneeId; + const users = await client.users({ + filter: { email: { eqIgnoreCase: assigneeId } }, + first: 2, + }); + if (users.nodes.length !== 1) { + throw new ConfigurationError( + "Linear could not resolve exactly one matching issue assignee.", + ); + } + return users.nodes[0]!.id; +} + async function publishLinearApiIssues( publication: PreparedScanPublication, handoffFile: string, diff --git a/sdk/typescript/tests-ts/cli-publish.test.ts b/sdk/typescript/tests-ts/cli-publish.test.ts index 743daf6f..df2862dc 100644 --- a/sdk/typescript/tests-ts/cli-publish.test.ts +++ b/sdk/typescript/tests-ts/cli-publish.test.ts @@ -4,6 +4,7 @@ import { dirname, join, resolve } from "node:path"; import { stripVTControlCharacters } from "node:util"; import { afterEach, describe, expect, test } from "bun:test"; import { main } from "../src/cli.js"; +import type { CheckScanPublicationResult } from "../src/publish.js"; import { capture, dependencies, FakeSignals } from "./cli-fixtures.js"; const DESTINATION_OPTIONS = [ @@ -73,7 +74,150 @@ function publicationResult( }; } +describe("publish check", () => { + test("resolves the shared destination options without invoking publication", async () => { + const stdout = capture(); + const stderr = capture(); + const currentDirectory = join(tmpdir(), "codex-security-check-current"); + const result: CheckScanPublicationResult = { + scanId: "scan-example", + destination: { + type: "linear", + teamId: "team-from-flags", + projectId: "project-from-flags", + }, + recorded: [], + counts: { findings: 2, recorded: 0, pending: 2 }, + access: { + transport: "linear-api", + authentication: "verified", + team: "verified", + project: "verified", + assignee: "verified", + issueCreation: "not-tested", + }, + }; + const deps = dependencies({ + currentDirectory, + environment: { + CODEX_SECURITY_LINEAR_API_KEY: "environment-key", + CODEX_SECURITY_LINEAR_TEAM: "environment-team", + }, + }); + deps.publishScan = async () => { + throw new Error("Check must not publish."); + }; + deps.checkScanPublication = async (directory, options) => { + expect(directory).toBe(resolve(currentDirectory, "completed-scan")); + expect(options).toEqual({ + destination: "linear", + teamId: "team-from-flags", + projectId: "project-from-flags", + linearApiKey: "explicit-key", + assigneeId: "teammate@example.com", + }); + return result; + }; + expect( + await main( + [ + "publish", + "check", + "completed-scan", + ...DESTINATION_OPTIONS, + "--linear-api-key", + "explicit-key", + "--linear-assignee", + "teammate@example.com", + "--json", + ], + stdout.stream, + stderr.stream, + deps, + ), + ).toBe(0); + expect(JSON.parse(stdout.text())).toEqual(result); + expect(stderr.text()).toBe(""); + expect(stdout.text()).not.toContain("explicit-key"); + expect(stdout.text()).not.toContain("teammate@example.com"); + }); + + test("reports a failed check without publishing or returning a successful result", async () => { + const stdout = capture(); + const stderr = capture(); + const deps = dependencies(); + deps.checkScanPublication = async () => { + throw new Error("The selected project is unavailable."); + }; + deps.publishScan = async () => { + throw new Error("Check must not publish."); + }; + expect( + await main( + [ + "publish", + "check", + "completed-scan", + ...DESTINATION_OPTIONS, + "--json", + ], + stdout.stream, + stderr.stream, + deps, + ), + ).toBe(2); + expect(stderr.text()).toContain("The selected project is unavailable."); + expect(stdout.text().trim()).toBe(""); + }); +}); + describe("publish scan", () => { + test("forwards opt-in retry and reports skipped issues separately", async () => { + for (const dryRun of [false, true]) { + const stdout = capture(); + const stderr = capture(); + const deps = dependencies(); + deps.publishScan = async (_directory, options) => { + expect(options.skipExisting).toBe(true); + expect(options.dryRun).toBe(dryRun); + const previous = publicationResult(); + return { + ...previous, + created: [], + skipped: previous.created, + counts: { findings: 1, created: 0, failed: 0, skipped: 1 }, + ...(dryRun ? { dryRun: true, issues: [] } : {}), + }; + }; + expect( + await main( + [ + "publish", + "scan", + "completed-scan", + ...DESTINATION_OPTIONS, + "--skip-existing", + ...(dryRun ? ["--dry-run", "--json"] : []), + ], + stdout.stream, + stderr.stream, + deps, + ), + ).toBe(0); + if (dryRun) { + expect(JSON.parse(stdout.text()).counts).toEqual({ + findings: 1, + created: 0, + failed: 0, + skipped: 1, + }); + } else { + expect(stdout.text()).toContain("0 total issues created"); + expect(stdout.text()).toContain("1 previously recorded issue skipped"); + } + } + }); + test("publishes an explicit scan directory without inspecting scan history", async () => { const currentDirectory = join(tmpdir(), "codex-security-publish-current"); const stdout = capture(); diff --git a/sdk/typescript/tests-ts/publication-check.test.ts b/sdk/typescript/tests-ts/publication-check.test.ts new file mode 100644 index 00000000..6876e061 --- /dev/null +++ b/sdk/typescript/tests-ts/publication-check.test.ts @@ -0,0 +1,272 @@ +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { describe, expect, test } from "bun:test"; +import { + checkScanPublicationInternal, + type CheckScanPublicationDependencies, + type CheckScanPublicationOptions, +} from "../src/publish.js"; +import type { PreparedScanPublication } from "../src/publication.js"; + +const OPTIONS: CheckScanPublicationOptions = { + destination: "linear", + teamId: "team-example", + projectId: "project-example", +}; +const PUBLICATION: PreparedScanPublication = { + scanId: "scan-example", + uploadId: "scan-example", + scanDirectory: join(tmpdir(), "completed-scan"), + destination: { + type: "linear", + teamId: "team-example", + projectId: "project-example", + }, + issues: [1, 2].map((number) => ({ + findingId: `finding-${number}`, + occurrenceId: `occurrence-${number}`, + title: `Synthetic finding ${number}`, + description: "Synthetic description that must stay local during preflight.", + })), +}; +const RECORDED = { + findingId: "finding-1", + occurrenceId: "occurrence-1", + issueIdentifier: "EXAMPLE-101", +}; +type ReadClient = ReturnType< + NonNullable +>; + +function dependencies( + overrides: Partial = {}, +): CheckScanPublicationDependencies { + return { + environment: {}, + prepare: async () => PUBLICATION, + inspectPublicationStore: async (publication) => { + expect(publication).toBe(PUBLICATION); + return [RECORDED]; + }, + ...overrides, + }; +} + +function readClient( + calls: unknown[], + options: { + teams?: readonly string[]; + active?: boolean; + archivedProject?: boolean; + retiredTeam?: boolean; + } = {}, +): ReadClient { + return { + get viewer() { + calls.push("viewer"); + return Promise.resolve({ id: "viewer-example" }); + }, + team: async (id: string) => { + calls.push(["team", id]); + return { + id: "canonical-team", + retiredAt: options.retiredTeam ? new Date(0) : undefined, + }; + }, + project: async (id: string) => { + calls.push(["project", id]); + return { + archivedAt: options.archivedProject ? new Date(0) : undefined, + teams: async (variables: unknown) => { + calls.push(["project.teams", variables]); + return { + nodes: (options.teams ?? ["canonical-team"]).map((team) => ({ + id: team, + })), + }; + }, + }; + }, + users: async (variables: unknown) => { + calls.push(["users", variables]); + return { nodes: [{ id: "assignee-example" }] }; + }, + user: async (id: string) => { + calls.push(["user", id]); + return { id, active: options.active ?? true }; + }, + createIssue: () => { + throw new Error("Preflight must not create issues."); + }, + } as unknown as ReadClient; +} + +describe("read-only publication preflight", () => { + test("reports local history without claiming connected-app access", async () => { + const result = await checkScanPublicationInternal( + "scan", + OPTIONS, + dependencies({ + linearClient: () => { + throw new Error("No remote client should be constructed."); + }, + }), + ); + expect(result).toEqual({ + scanId: PUBLICATION.scanId, + destination: PUBLICATION.destination, + recorded: [RECORDED], + counts: { findings: 2, recorded: 1, pending: 1 }, + access: { + transport: "connected-app", + authentication: "not-checked", + team: "not-checked", + project: "not-checked", + assignee: "not-requested", + issueCreation: "not-tested", + }, + }); + expect(JSON.stringify(result)).not.toContain( + PUBLICATION.issues[0]!.description, + ); + }); + + test("uses only read queries for direct API access and omits credentials and identities", async () => { + const calls: unknown[] = []; + const key = "lin_api_SYNTHETIC_PREFLIGHT_KEY"; + const assignee = "teammate@example.com"; + const signal = new AbortController().signal; + const result = await checkScanPublicationInternal( + "scan", + { ...OPTIONS, linearApiKey: key, assigneeId: assignee, signal }, + dependencies({ + environment: { CODEX_SECURITY_LINEAR_API_KEY: "environment-key" }, + linearClient: (configuration) => { + expect(configuration).toEqual({ + apiKey: key, + redirect: "error", + signal, + }); + return readClient(calls); + }, + }), + ); + expect(calls).toEqual([ + "viewer", + ["team", "team-example"], + ["project", "project-example"], + ["project.teams", { filter: { id: { eq: "canonical-team" } }, first: 1 }], + ["users", { filter: { email: { eqIgnoreCase: assignee } }, first: 2 }], + ["user", "assignee-example"], + ]); + expect(result.access).toEqual({ + transport: "linear-api", + authentication: "verified", + team: "verified", + project: "verified", + assignee: "verified", + issueCreation: "not-tested", + }); + for (const privateValue of [ + key, + assignee, + "viewer-example", + "assignee-example", + PUBLICATION.issues[0]!.description, + ]) { + expect(JSON.stringify(result)).not.toContain(privateValue); + } + }); + + test("checks team-only destinations without requesting a project or assignee", async () => { + const calls: unknown[] = []; + const publication = { + ...PUBLICATION, + destination: { type: "linear" as const, teamId: OPTIONS.teamId }, + }; + const result = await checkScanPublicationInternal( + "scan", + { destination: "linear", teamId: OPTIONS.teamId }, + dependencies({ + environment: { CODEX_SECURITY_LINEAR_API_KEY: "environment-key" }, + prepare: async () => publication, + inspectPublicationStore: async () => [], + linearClient: () => readClient(calls), + }), + ); + expect(calls).toEqual(["viewer", ["team", "team-example"]]); + expect(result.access.project).toBe("not-requested"); + expect(result.access.assignee).toBe("not-requested"); + expect(result.access.issueCreation).toBe("not-tested"); + }); + + test("rejects unavailable or incompatible destinations and inactive assignees", async () => { + for (const [clientOptions, message] of [ + [{ teams: [] }, /does not belong/u], + [{ archivedProject: true }, /project is archived/u], + [{ retiredTeam: true }, /team is archived or retired/u], + [{ active: false }, /assignee is inactive/u], + ] as const) { + await expect( + checkScanPublicationInternal( + "scan", + { + ...OPTIONS, + linearApiKey: "synthetic-key", + assigneeId: "assignee-example", + }, + dependencies({ + linearClient: () => readClient([], clientOptions), + }), + ), + ).rejects.toThrow(message); + } + }); + + test("verifies local history before contacting Linear and preserves cancellation", async () => { + const calls: unknown[] = []; + await expect( + checkScanPublicationInternal( + "scan", + { ...OPTIONS, linearApiKey: "synthetic-key" }, + dependencies({ + inspectPublicationStore: async () => { + throw new Error("Missing local history."); + }, + linearClient: () => readClient(calls), + }), + ), + ).rejects.toThrow("Missing local history."); + const controller = new AbortController(); + controller.abort(new Error("Canceled preflight.")); + await expect( + checkScanPublicationInternal( + "scan", + { ...OPTIONS, signal: controller.signal }, + dependencies({ + prepare: async () => { + throw new Error("Must not prepare after cancellation."); + }, + }), + ), + ).rejects.toThrow("Canceled preflight."); + expect(calls).toEqual([]); + }); + + test("does not echo provider response data on an access failure", async () => { + const key = "lin_api_SYNTHETIC_PRIVATE_KEY"; + const client = readClient([]); + client.team = (() => { + throw new Error(`Provider response included ${key}`); + }) as ReadClient["team"]; + await expect( + checkScanPublicationInternal( + "scan", + { ...OPTIONS, linearApiKey: key }, + dependencies({ linearClient: () => client }), + ), + ).rejects.toThrow( + "Could not verify Linear team access. Check the API key and publication destination.", + ); + }); +}); diff --git a/sdk/typescript/tests-ts/publication-integration.test.ts b/sdk/typescript/tests-ts/publication-integration.test.ts index b3f959a7..e4688057 100644 --- a/sdk/typescript/tests-ts/publication-integration.test.ts +++ b/sdk/typescript/tests-ts/publication-integration.test.ts @@ -23,6 +23,7 @@ import type { ScanManifest, } from "../src/models.js"; import { + checkScanPublicationInternal, publishScanInternal, type PublishScanDependencies, type PublishScanProgress, @@ -280,6 +281,158 @@ function receiptPath(fixture: PublicationFixture): string { } describe("database-backed Linear publication integration", () => { + test("checks and retries a partial publication without duplicating recorded successes", async () => { + const completed = await fixture(2); + const sealed = await artifactDigests(completed.scanDirectory); + const environment = { + ...completed.environment, + CODEX_SECURITY_LINEAR_API_KEY: "lin_api_SYNTHETIC_RETRY_KEY", + }; + const cli = dependencies({ environment }); + type LinearClient = ReturnType< + NonNullable + >; + type IssueInput = Parameters[0]; + const attempted: string[] = []; + let failSecond = true; + let issueNumber = 500; + cli.publishScan = (directory, options) => + publishScanInternal(directory, options, { + environment, + resolveCodex: () => { + throw new Error("Direct publication must not start Codex."); + }, + linearClient: () => + ({ + users: async () => { + throw new Error("Unassigned publication must not look up users."); + }, + createIssue: async (input: IssueInput) => { + const index = completed.findings.findIndex(({ findingId }) => + input.description?.includes(findingId), + ); + expect(index).toBeGreaterThanOrEqual(0); + attempted.push(completed.findings[index]!.findingId); + if (failSecond && index === 1) + throw new Error("Synthetic creation failure."); + const identifier = `EXAMPLE-${++issueNumber}`; + return { + success: true, + issue: Promise.resolve({ + identifier, + url: `https://linear.app/example/issue/${identifier}`, + }), + }; + }, + }) as unknown as LinearClient, + }); + const command = [ + "publish", + "scan", + completed.scanDirectory, + "--to", + "linear", + "--linear-team", + OPTIONS.teamId, + "--project", + OPTIONS.projectId, + "--json", + ]; + const run = async (flags: string[] = []) => { + const stdout = capture(); + const stderr = capture(); + const code = await main( + [...command, ...flags], + stdout.stream, + stderr.stream, + cli, + ); + return { code, result: JSON.parse(stdout.text()) as PublishScanResult }; + }; + + const initial = await run(); + expect(initial.code).toBe(2); + expect(initial.result.counts).toEqual({ + findings: 2, + created: 1, + failed: 1, + }); + expect(storedPublications(completed)).toHaveLength(1); + + const localEnvironment = { + ...completed.environment, + CODEX_SECURITY_LINEAR_API_KEY: undefined, + }; + const checkCli = dependencies({ environment: localEnvironment }); + checkCli.checkScanPublication = (directory, options) => + checkScanPublicationInternal(directory, options, { + environment: localEnvironment, + }); + const checkOutput = capture(); + const database = join(completed.stateDirectory, "workbench.sqlite3"); + const before = sha256(await readFile(database)); + expect( + await main( + [ + "publish", + "check", + completed.scanDirectory, + "--to", + "linear", + "--linear-team", + OPTIONS.teamId, + "--project", + OPTIONS.projectId, + "--json", + ], + checkOutput.stream, + capture().stream, + checkCli, + ), + ).toBe(0); + const checked = JSON.parse(checkOutput.text()); + expect(checked.counts).toEqual({ findings: 2, recorded: 1, pending: 1 }); + expect(checked.access.issueCreation).toBe("not-tested"); + expect(checked.recorded).toEqual(initial.result.created); + expect(sha256(await readFile(database))).toBe(before); + + failSecond = false; + attempted.length = 0; + const retry = await run(["--skip-existing"]); + expect(retry.code).toBe(0); + expect(attempted).toEqual([completed.findings[1]!.findingId]); + expect(retry.result.skipped).toEqual(initial.result.created); + expect(retry.result.counts).toEqual({ + findings: 2, + created: 1, + failed: 0, + skipped: 1, + }); + expect(storedPublications(completed)).toHaveLength(2); + + const receipt = await readFile(receiptPath(completed), "utf8"); + attempted.length = 0; + const repeated = await run(["--skip-existing"]); + expect(repeated.code).toBe(0); + expect(repeated.result.counts).toEqual({ + findings: 2, + created: 0, + failed: 0, + skipped: 2, + }); + expect(attempted).toEqual([]); + expect(await readFile(receiptPath(completed), "utf8")).toBe(receipt); + expect(storedPublications(completed)).toHaveLength(2); + + expect((await run()).result.counts).toEqual({ + findings: 2, + created: 2, + failed: 0, + }); + expect(storedPublications(completed)).toHaveLength(4); + expect(await artifactDigests(completed.scanDirectory)).toEqual(sealed); + }); + test("persists unassigned direct team-only publication", async () => { const completed = await fixture(23); const sealed = await artifactDigests(completed.scanDirectory); diff --git a/sdk/typescript/tests-ts/publication-store.test.ts b/sdk/typescript/tests-ts/publication-store.test.ts index 6fb9762c..5dbf2407 100644 --- a/sdk/typescript/tests-ts/publication-store.test.ts +++ b/sdk/typescript/tests-ts/publication-store.test.ts @@ -1,11 +1,20 @@ import { spawnSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; -import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + readFile, + readdir, + realpath, + rm, + stat, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, test } from "bun:test"; import { + inspectPublicationStore, preparePublicationStore, recordPublishedIssues, } from "../src/publication-store.js"; @@ -164,6 +173,152 @@ function publishedIssue( }; } +describe("read-only publication history", () => { + test("does not create a missing database or migrate old history", async () => { + const missing = await publicationFixture({ createDatabase: false }); + await expect( + inspectPublicationStore(missing.publication, missing.environment), + ).rejects.toThrow(/scan-history database does not exist/u); + expect(existsSync(missing.stateDirectory)).toBe(false); + + const fixture = await publicationFixture(); + databaseRows(fixture, "DROP TABLE finding_publications"); + databaseRows(fixture, "DELETE FROM schema_migrations WHERE version >= ?", [ + 29, + ]); + const database = join(fixture.stateDirectory, "workbench.sqlite3"); + const before = await readFile(database); + const mode = (await stat(database)).mode; + await expect( + inspectPublicationStore(fixture.publication, fixture.environment), + ).resolves.toEqual([]); + expect(await readFile(database)).toEqual(before); + expect((await stat(database)).mode).toBe(mode); + expect( + (await readdir(fixture.stateDirectory)).some((name) => + name.startsWith("publication-"), + ), + ).toBe(false); + expect( + databaseRows( + fixture, + "SELECT version FROM schema_migrations WHERE version >= ?", + [29], + ), + ).toEqual([]); + expect( + databaseRows( + fixture, + "SELECT name FROM sqlite_master WHERE name = 'finding_publications'", + ), + ).toEqual([]); + }); + + test("returns one recorded issue per exact scan occurrence and destination", async () => { + const fixture = await publicationFixture(); + const first = publishedIssue(fixture.publication, 0, "EXAMPLE-101"); + const second = publishedIssue(fixture.publication, 1, "EXAMPLE-102"); + await recordPublishedIssues( + fixture.publication, + [second, first], + fixture.environment, + ); + await recordPublishedIssues( + fixture.publication, + [publishedIssue(fixture.publication, 0, "EXAMPLE-201")], + fixture.environment, + ); + const teamOnly: PreparedScanPublication = { + ...fixture.publication, + destination: { type: "linear", teamId: "team-example" }, + }; + const withoutProject = publishedIssue(teamOnly, 1, "EXAMPLE-301"); + await recordPublishedIssues( + teamOnly, + [withoutProject], + fixture.environment, + ); + + await expect( + inspectPublicationStore(fixture.publication, fixture.environment), + ).resolves.toEqual([first, second]); + await expect( + inspectPublicationStore(teamOnly, fixture.environment), + ).resolves.toEqual([withoutProject]); + for (const destination of [ + { ...fixture.publication.destination, teamId: "another-team" }, + { ...fixture.publication.destination, projectId: "another-project" }, + ]) { + await expect( + inspectPublicationStore( + { ...fixture.publication, destination }, + fixture.environment, + ), + ).resolves.toEqual([]); + } + + const scanDirectory = join(fixture.stateDirectory, "another-scan"); + await mkdir(scanDirectory, { mode: 0o700 }); + const otherScan: PreparedScanPublication = { + ...fixture.publication, + scanId: OTHER_SCAN_ID, + uploadId: OTHER_SCAN_ID, + scanDirectory, + issues: fixture.publication.issues.map((issue) => ({ + ...issue, + occurrenceId: `other-${issue.occurrenceId}`, + })), + }; + seedPublicationScan(fixture, otherScan); + await expect( + inspectPublicationStore(otherScan, fixture.environment), + ).resolves.toEqual([]); + await expect( + inspectPublicationStore( + { ...fixture.publication, issues: [fixture.publication.issues[0]!] }, + fixture.environment, + ), + ).rejects.toThrow(/exactly match/u); + }); + + test("includes committed associations that are still in the WAL", async () => { + const fixture = await publicationFixture({ count: 1 }); + const original = publishedIssue(fixture.publication, 0, "EXAMPLE-401"); + const changed = publishedIssue(fixture.publication, 0, "EXAMPLE-402"); + await recordPublishedIssues( + fixture.publication, + [original], + fixture.environment, + ); + const database = join(fixture.stateDirectory, "workbench.sqlite3"); + const update = spawnSync( + fixture.python, + [ + "-I", + "-B", + "-c", + [ + "import os, sqlite3, sys", + "connection = sqlite3.connect(sys.argv[1])", + "connection.execute('PRAGMA wal_autocheckpoint = 0')", + "connection.execute('UPDATE finding_publications SET external_id = ?, external_url = ?', (sys.argv[2], sys.argv[3]))", + "connection.commit()", + "os._exit(0)", + ].join("\n"), + database, + changed.issueIdentifier, + changed.url!, + ], + { encoding: "utf8" }, + ); + expect(update.status, update.stderr).toBe(0); + expect(existsSync(`${database}-wal`)).toBe(true); + await expect( + inspectPublicationStore(fixture.publication, fixture.environment), + ).resolves.toEqual([changed]); + }); +}); + describe("persisted finding publication associations", () => { test("upgrades existing scan history and verifies every completed finding before publication", async () => { const fixture = await publicationFixture(); diff --git a/sdk/typescript/tests-ts/publish.test.ts b/sdk/typescript/tests-ts/publish.test.ts index 197f6c0b..77aab79b 100644 --- a/sdk/typescript/tests-ts/publish.test.ts +++ b/sdk/typescript/tests-ts/publish.test.ts @@ -18,6 +18,7 @@ import { type PublishScanDependencies, type PublishScanOptions, type PublishScanProgress, + type PublishedScanIssue, } from "../src/publish.js"; import type { PreparedPublicationIssue, @@ -267,6 +268,210 @@ async function processHasExited(pid: number): Promise { return false; } +describe("skip-recorded publication", () => { + test("keeps the default create-new behavior and makes opt-in previews read-only", async () => { + const publication = preparedPublication(2); + const recorded: PublishedScanIssue = { + findingId: "finding-1", + occurrenceId: "occurrence-1", + issueIdentifier: "SEC-101", + }; + const injected = dependencies( + publication, + {}, + { + inspectPublicationStore: async (prepared) => { + expect(prepared).toBe(publication); + return [recorded]; + }, + preparePublicationStore: async () => { + throw new Error("Previews must not write history."); + }, + resolveCodex: () => { + throw new Error("Previews must not start Codex."); + }, + writeReceipt: async () => { + throw new Error("Previews must not write receipts."); + }, + }, + ); + const ordinary = await publishScanInternal( + "scan", + { ...OPTIONS, dryRun: true }, + { + ...injected, + inspectPublicationStore: async () => { + throw new Error("Ordinary previews stay offline."); + }, + }, + ); + expect(ordinary.issues).toEqual(publication.issues); + expect(ordinary).not.toHaveProperty("skipped"); + const preview = await publishScanInternal( + "scan", + { ...OPTIONS, dryRun: true, skipExisting: true }, + injected, + ); + expect(preview.issues).toEqual([publication.issues[1]!]); + expect(preview.skipped).toEqual([recorded]); + expect(preview.counts).toEqual({ + findings: 2, + created: 0, + failed: 0, + skipped: 1, + }); + }); + + test("does nothing remotely or locally when every finding is already recorded", async () => { + const publication = preparedPublication(); + const recorded: PublishedScanIssue = { + findingId: "finding-1", + occurrenceId: "occurrence-1", + issueIdentifier: "SEC-101", + }; + const result = await publishScanInternal( + "scan", + { ...OPTIONS, skipExisting: true }, + dependencies( + publication, + {}, + { + inspectPublicationStore: async () => [recorded], + preparePublicationStore: async () => { + throw new Error("Nothing needs publication."); + }, + resolveCodex: () => { + throw new Error("Nothing needs publication."); + }, + linearClient: () => { + throw new Error("Nothing needs publication."); + }, + recordPublishedIssues: async () => { + throw new Error("Nothing needs publication."); + }, + writeReceipt: async () => { + throw new Error("Nothing needs publication."); + }, + }, + ), + ); + expect(result.created).toEqual([]); + expect(result.skipped).toEqual([recorded]); + expect(result.counts).toEqual({ + findings: 1, + created: 0, + failed: 0, + skipped: 1, + }); + }); + + test("publishes only pending findings while validating and recording against the full scan", async () => { + for (const transport of ["connected-app", "linear-api"] as const) { + const publication = preparedPublication(2); + const recorded: PublishedScanIssue = { + findingId: "finding-1", + occurrenceId: "occurrence-1", + issueIdentifier: "SEC-101", + }; + const attempted: string[] = []; + const progress: PublishScanProgress[] = []; + const injected = dependencies( + publication, + {}, + { + inspectPublicationStore: async () => [recorded], + preparePublicationStore: async (prepared) => { + expect(prepared).toBe(publication); + }, + recordPublishedIssues: async (prepared, issues) => { + expect(prepared).toBe(publication); + expect(issues.map((issue) => issue.findingId)).toEqual([ + "finding-2", + ]); + return [...issues]; + }, + runCodex: async (_command, _args, input) => { + const payload = publicationData(input); + attempted.push( + ...payload.batches.flat().map((issue) => issue.findingId), + ); + return { + exitCode: 0, + stdout: issueEvent(publication.issues[1]!), + stderr: "", + }; + }, + linearClient: linearApiClient(publication, { + create: (input) => { + attempted.push( + publication.issues.find((issue) => issue.title === input.title)! + .findingId, + ); + }, + }), + }, + ); + delete injected.environment!["CODEX_SECURITY_LINEAR_API_KEY"]; + const result = await publishScanInternal( + "scan", + { + ...OPTIONS, + skipExisting: true, + ...(transport === "linear-api" + ? { linearApiKey: "synthetic-key" } + : {}), + onProgress: (event) => progress.push(event), + }, + injected, + ); + expect(attempted).toEqual(["finding-2"]); + expect(result.skipped).toEqual([recorded]); + expect(result.created.map((issue) => issue.findingId)).toEqual([ + "finding-2", + ]); + expect(result.counts).toEqual({ + findings: 2, + created: 1, + failed: 0, + skipped: 1, + }); + expect(progress[0]).toEqual({ + type: "started", + scanId: publication.scanId, + total: 1, + }); + expect(progress.at(-1)).toEqual({ + type: "completed", + created: 1, + failed: 0, + total: 1, + }); + } + }); + + test("stops an opt-in retry when its history cannot be verified", async () => { + const publication = preparedPublication(); + await expect( + publishScanInternal( + "scan", + { ...OPTIONS, skipExisting: true }, + dependencies( + publication, + {}, + { + inspectPublicationStore: async () => { + throw new Error("History is unavailable."); + }, + resolveCodex: () => { + throw new Error("Must not publish without verified history."); + }, + }, + ), + ), + ).rejects.toThrow("History is unavailable."); + }); +}); + describe("direct Linear API publication", () => { test("leaves issues unassigned unless an email or user ID is selected", async () => { for (const scenario of [ From 4aa50c13af0b9550614c6088a3f1daf15a8767d2 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 16 Aug 2026 01:53:04 -0700 Subject: [PATCH 2/2] fix: cancel publication history inspection --- sdk/typescript/src/publication-store.ts | 8 +++ sdk/typescript/src/publish.ts | 4 +- .../tests-ts/publication-check.test.ts | 26 +++++++ .../tests-ts/publication-store.test.ts | 68 ++++++++++++++++++- sdk/typescript/tests-ts/publish.test.ts | 34 ++++++++++ 5 files changed, 136 insertions(+), 4 deletions(-) diff --git a/sdk/typescript/src/publication-store.ts b/sdk/typescript/src/publication-store.ts index f32b97de..4f969403 100644 --- a/sdk/typescript/src/publication-store.ts +++ b/sdk/typescript/src/publication-store.ts @@ -14,11 +14,14 @@ import { export async function inspectPublicationStore( publication: PreparedScanPublication, environment: NodeJS.ProcessEnv, + signal?: AbortSignal, ): Promise { const result = await runPublicationWorkbench( "inspect-linear-publication", publication, environment, + undefined, + signal, ); const recorded = result["recorded"]; if ( @@ -120,7 +123,9 @@ async function runPublicationWorkbench( publication: PreparedScanPublication, environment: NodeJS.ProcessEnv, issues?: readonly PublishedScanIssue[], + signal?: AbortSignal, ): Promise> { + signal?.throwIfAborted(); const stateDirectory = codexSecurityStateDirectory(environment); const database = join(stateDirectory, "workbench.sqlite3"); try { @@ -135,9 +140,11 @@ async function runPublicationWorkbench( resolvePluginPython({ environment, protectedRoot: publication.scanDirectory, + ...(signal === undefined ? {} : { signal }), }), bundledPluginRoot(), ]); + signal?.throwIfAborted(); const findings = publication.issues.map(({ findingId, occurrenceId }) => ({ findingId, occurrenceId, @@ -166,6 +173,7 @@ async function runPublicationWorkbench( python, pluginRoot, environment, + ...(signal === undefined ? {} : { signal }), failureMessage: command === "record-linear-publications" ? "Could not persist created Linear issues in the local Codex Security scan history" diff --git a/sdk/typescript/src/publish.ts b/sdk/typescript/src/publish.ts index f5357801..bbda61a5 100644 --- a/sdk/typescript/src/publish.ts +++ b/sdk/typescript/src/publish.ts @@ -192,7 +192,7 @@ export async function publishScanInternal( if (options.skipExisting) { result.skipped = await ( dependencies.inspectPublicationStore ?? inspectPublicationStore - )(preparedScan, environment); + )(preparedScan, environment, options.signal); result.counts.skipped = result.skipped.length; const recorded = new Set(result.skipped.map((issue) => issue.findingId)); prepared = { @@ -424,7 +424,7 @@ export async function checkScanPublicationInternal( ); const recorded = await ( dependencies.inspectPublicationStore ?? inspectPublicationStore - )(prepared, environment); + )(prepared, environment, options.signal); options.signal?.throwIfAborted(); const result: CheckScanPublicationResult = { scanId: prepared.scanId, diff --git a/sdk/typescript/tests-ts/publication-check.test.ts b/sdk/typescript/tests-ts/publication-check.test.ts index 6876e061..7ce75565 100644 --- a/sdk/typescript/tests-ts/publication-check.test.ts +++ b/sdk/typescript/tests-ts/publication-check.test.ts @@ -253,6 +253,32 @@ describe("read-only publication preflight", () => { expect(calls).toEqual([]); }); + test("forwards cancellation while history inspection is in progress", async () => { + const controller = new AbortController(); + const reason = new Error("History check canceled."); + await expect( + checkScanPublicationInternal( + "scan", + { ...OPTIONS, signal: controller.signal }, + dependencies({ + inspectPublicationStore: async ( + _publication, + _environment, + signal, + ) => { + expect(signal).toBe(controller.signal); + controller.abort(reason); + signal!.throwIfAborted(); + return []; + }, + linearClient: () => { + throw new Error("Canceled checks must not contact Linear."); + }, + }), + ), + ).rejects.toBe(reason); + }); + test("does not echo provider response data on an access failure", async () => { const key = "lin_api_SYNTHETIC_PRIVATE_KEY"; const client = readClient([]); diff --git a/sdk/typescript/tests-ts/publication-store.test.ts b/sdk/typescript/tests-ts/publication-store.test.ts index 5dbf2407..592a9379 100644 --- a/sdk/typescript/tests-ts/publication-store.test.ts +++ b/sdk/typescript/tests-ts/publication-store.test.ts @@ -11,8 +11,8 @@ import { stat, } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, test } from "bun:test"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { inspectPublicationStore, preparePublicationStore, @@ -21,6 +21,7 @@ import { import type { PreparedScanPublication } from "../src/publication.js"; import type { PublishedScanIssue } from "../src/publish.js"; import { runWorkbench } from "../src/runtime.js"; +import * as runtime from "../src/runtime.js"; import { PLUGIN_ROOT } from "./plugin-root.js"; const SCAN_ID = "22222222-2222-4222-8222-222222222222"; @@ -174,6 +175,69 @@ function publishedIssue( } describe("read-only publication history", () => { + test("forwards cancellation to Python discovery and the workbench and cleans up its input", async () => { + const fixture = await publicationFixture(); + const controller = new AbortController(); + const reason = new Error("Synthetic inspection cancellation."); + let inputFile = ""; + let started!: () => void; + const inspecting = new Promise((resolve) => { + started = resolve; + }); + const python = spyOn(runtime, "resolvePluginPython").mockImplementation( + async (options) => { + expect(options?.signal).toBe(controller.signal); + return fixture.python; + }, + ); + const workbench = spyOn(runtime, "runWorkbench").mockImplementation( + async (options, args) => { + started(); + expect(options.signal).toBe(controller.signal); + expect(args[0]).toBe("inspect-linear-publication"); + inputFile = args[args.indexOf("--input-file") + 1]!; + return new Promise((_resolve, reject) => { + options.signal!.addEventListener( + "abort", + () => reject(options.signal!.reason), + { once: true }, + ); + }); + }, + ); + try { + const pending = inspectPublicationStore( + fixture.publication, + fixture.environment, + controller.signal, + ); + await inspecting; + controller.abort(reason); + await expect(pending).rejects.toBe(reason); + expect(inputFile).not.toBe(""); + expect(existsSync(dirname(inputFile))).toBe(false); + } finally { + controller.abort(reason); + workbench.mockRestore(); + python.mockRestore(); + } + }); + + test("rejects a pre-aborted inspection before looking for local history", async () => { + const fixture = await publicationFixture({ createDatabase: false }); + const controller = new AbortController(); + const reason = new Error("Inspection already canceled."); + controller.abort(reason); + await expect( + inspectPublicationStore( + fixture.publication, + fixture.environment, + controller.signal, + ), + ).rejects.toBe(reason); + expect(existsSync(fixture.stateDirectory)).toBe(false); + }); + test("does not create a missing database or migrate old history", async () => { const missing = await publicationFixture({ createDatabase: false }); await expect( diff --git a/sdk/typescript/tests-ts/publish.test.ts b/sdk/typescript/tests-ts/publish.test.ts index 77aab79b..ebe76851 100644 --- a/sdk/typescript/tests-ts/publish.test.ts +++ b/sdk/typescript/tests-ts/publish.test.ts @@ -269,6 +269,40 @@ async function processHasExited(pid: number): Promise { } describe("skip-recorded publication", () => { + test("forwards cancellation into read-only history inspection", async () => { + const publication = preparedPublication(); + const controller = new AbortController(); + const reason = new Error("Retry inspection canceled."); + await expect( + publishScanInternal( + "scan", + { ...OPTIONS, skipExisting: true, signal: controller.signal }, + dependencies( + publication, + {}, + { + inspectPublicationStore: async ( + _publication, + _environment, + signal, + ) => { + expect(signal).toBe(controller.signal); + controller.abort(reason); + signal!.throwIfAborted(); + return []; + }, + preparePublicationStore: async () => { + throw new Error("Canceled retries must not write history."); + }, + resolveCodex: () => { + throw new Error("Canceled retries must not start Codex."); + }, + }, + ), + ), + ).rejects.toBe(reason); + }); + test("keeps the default create-new behavior and makes opt-in previews read-only", async () => { const publication = preparedPublication(2); const recorded: PublishedScanIssue = {