From 60ad828660e2e8f9202b5d5fc082411380db0d2c Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 8 Oct 2026 02:09:50 +0000 Subject: [PATCH 01/11] feat!: share finding issue receipts across publication and tracking --- docs/plugin-connectors.md | 2 + plugins/codex-security/mcp-app/server.ts | 12 +- .../mcp-app/src/server/finding-issue-tools.ts | 96 ++++ .../tests/test_compact_artifact_server.ts | 2 + .../mcp-app/tests/test_finding_issue_tools.ts | 168 ++++++ .../mcp-app/tests/test_workbench_database.ts | 8 +- .../tests/test_workbench_state_fallback.ts | 111 ++++ .../codex-security/scripts/workbench_cli.py | 8 +- .../codex-security/scripts/workbench_db.py | 12 +- .../scripts/workbench_publication.py | 466 +++++++--------- .../shared/workbench-migrations.json | 10 + .../skills/track-findings/SKILL.md | 12 +- .../references/github-security-advisories.md | 2 +- .../tests/test_finding_issue_receipts.py | 246 +++++++++ .../codex-security/tests/test_workbench_db.py | 7 +- .../tests/test_workbench_deep_scan.py | 5 +- .../test_workbench_setup_and_migrations.py | 18 +- sdk/typescript/docs/cli.md | 10 +- sdk/typescript/src/publication-store.ts | 252 +++------ .../tests-ts/publication-integration.test.ts | 115 +++- .../tests-ts/publication-store.test.ts | 511 ++++++++---------- sdk/typescript/tests-ts/runtime.test.ts | 2 +- 22 files changed, 1311 insertions(+), 764 deletions(-) create mode 100644 plugins/codex-security/mcp-app/src/server/finding-issue-tools.ts create mode 100644 plugins/codex-security/mcp-app/tests/test_finding_issue_tools.ts create mode 100644 plugins/codex-security/tests/test_finding_issue_receipts.py diff --git a/docs/plugin-connectors.md b/docs/plugin-connectors.md index 69558b844e..3157e3feeb 100644 --- a/docs/plugin-connectors.md +++ b/docs/plugin-connectors.md @@ -8,6 +8,8 @@ Codex Security uses optional connected apps for ticket intake and finding tracki | GitHub | Finding intake, source lookup, and issue tracking | | Atlassian | Jira Cloud ticket intake and finding tracking | +Finding tracking and CLI publication share issue associations when they use the same local state directory, including reuse across scan occurrences. The tracking skill reads them with `get_codex_security_finding_issues` and records accepted writes or verified reuse with `record_codex_security_finding_issues`. Provider reads still confirm saved candidates and search for unrecorded duplicates. Accepted writes remain recorded even when follow-up reads fail. The tools accept sealed scan bundles without requiring their original local scan history and keep receipts outside those bundles. + ## Jira setup Connect the **Atlassian** app in the app directory. Existing Atlassian Rovo (Legacy) connections do not connect the new app; authorize it separately with the intended account and site. diff --git a/plugins/codex-security/mcp-app/server.ts b/plugins/codex-security/mcp-app/server.ts index 4c27108ac7..d6365245ed 100644 --- a/plugins/codex-security/mcp-app/server.ts +++ b/plugins/codex-security/mcp-app/server.ts @@ -24,6 +24,7 @@ import { type HandoffWorkspaceState as WorkspaceState, } from "./src/server/handoff-tools.js"; import { registerCompactArtifactTools } from "./src/server/compact-artifact-tools.js"; +import { registerFindingIssueTools } from "./src/server/finding-issue-tools.js"; import { createScanArtifactContext } from "./src/artifact-context.js"; import { recordCodexSecurityScanDraftViaWorkbench } from "./src/artifact-scan-draft.js"; import { @@ -2035,6 +2036,7 @@ export function createCodexSecurityServer(): McpServer { ), ); + registerFindingIssueTools(server, runWorkbench); registerCompactArtifactTools(server, { runWorkbench, pluginRoot: PLUGIN_ROOT, @@ -2461,7 +2463,15 @@ async function executeWorkbenchWithStateSelection( undefined, input, ); - persistentWorkbenchStateSucceeded = true; + // A lookup without a database must not pin the store; existing history must. + if ( + args[0] !== "finding-issues" || + input === undefined || + JSON.parse(input.toString()).action !== "inspect" || + result.storeExists !== false + ) { + persistentWorkbenchStateSucceeded = true; + } return result; } catch (error) { if (!isUnwritableSqliteOpenError(error)) throw error; diff --git a/plugins/codex-security/mcp-app/src/server/finding-issue-tools.ts b/plugins/codex-security/mcp-app/src/server/finding-issue-tools.ts new file mode 100644 index 0000000000..b1ad13e039 --- /dev/null +++ b/plugins/codex-security/mcp-app/src/server/finding-issue-tools.ts @@ -0,0 +1,96 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import * as z from "zod/v4"; +import type { JsonObject } from "../types.js"; +import { readingAnnotations, writingAnnotations } from "./tool-annotations.js"; + +const identifier = z.string().min(1); +const destinationSchema = z.discriminatedUnion("type", [ + z + .object({ + type: z.literal("linear"), + teamId: identifier, + projectId: identifier.optional(), + }) + .strict(), + z + .object({ + type: z.literal("jira"), + cloudId: identifier, + projectId: identifier, + }) + .strict(), + z + .object({ + type: z.enum(["github-issue", "github-advisory"]), + hostname: identifier, + repository: identifier, + }) + .strict(), +]); +const sourceSchema = z + .object({ + scanDirectory: identifier, + destination: destinationSchema, + findingIds: z.array(identifier).optional(), + }) + .strict(); +const receiptSchema = z + .object({ + findingId: identifier, + occurrenceId: identifier, + issueIdentifier: identifier, + url: identifier.optional(), + operation: z.enum(["create", "update", "reuse"]), + readback: z + .object({ + status: z.enum(["verified", "failed"]), + error: z.string().optional(), + }) + .strict() + .optional(), + }) + .strict(); + +export function registerFindingIssueTools( + server: McpServer, + runWorkbench: ( + args: string[], + input?: string | Buffer, + ) => Promise, +): void { + const meta = { ui: { visibility: ["model"] } }; + const execute = async (action: "inspect" | "record", input: object) => { + const result = await runWorkbench( + ["finding-issues"], + JSON.stringify({ action, ...input }), + ); + return { + content: [{ type: "text" as const, text: JSON.stringify(result) }], + structuredContent: result, + }; + }; + server.registerTool( + "get_codex_security_finding_issues", + { + title: "Get Codex Security Finding Issues", + description: + "Validate a sealed scan and read its findings' saved issue associations for the exact destination, including earlier scan occurrences. Saved receipts are duplicate candidates; verify them through the provider before reusing an issue. Does not contact providers or require local scan history.", + inputSchema: sourceSchema, + annotations: readingAnnotations, + _meta: meta, + }, + (input) => execute("inspect", input), + ); + server.registerTool( + "record_codex_security_finding_issues", + { + title: "Record Codex Security Finding Issues", + description: + "Validate a sealed scan and save accepted provider creates or updates, or verified issue reuse, in the shared local issue history. Record the returned identity immediately; repeat the same receipt with readback status after an optional provider read. A failed read or local save does not undo an accepted write or authorize another create. Does not contact providers or modify the scan bundle.", + inputSchema: sourceSchema.extend({ receipts: z.array(receiptSchema) }), + annotations: writingAnnotations, + _meta: meta, + }, + (input) => execute("record", input), + ); +} diff --git a/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.ts b/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.ts index 2bab97e390..bd0cc9c0ee 100644 --- a/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.ts +++ b/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.ts @@ -1130,6 +1130,8 @@ async function testParentToolList(bundle: string) { "record_candidate_attack_paths", "record_codex_security_scan_draft", "get_codex_security_completed_scan", + "get_codex_security_finding_issues", + "record_codex_security_finding_issues", "complete_codex_security_scan", ]) { assert.equal(names.has(name), true, `Missing parent MCP tool ${name}.`); diff --git a/plugins/codex-security/mcp-app/tests/test_finding_issue_tools.ts b/plugins/codex-security/mcp-app/tests/test_finding_issue_tools.ts new file mode 100644 index 0000000000..db272863bd --- /dev/null +++ b/plugins/codex-security/mcp-app/tests/test_finding_issue_tools.ts @@ -0,0 +1,168 @@ +import assert from "node:assert/strict"; +import { cp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import type { TextContent } from "@modelcontextprotocol/sdk/types.js"; +import { applicationRoot, buildServer } from "./build-server.ts"; +import { temporaryDirectory } from "./support/temporary-directories.ts"; + +const pluginRoot = path.resolve(applicationRoot, ".."); +const runtimePluginRoot = process.env.CODEX_SECURITY_TEST_PLUGIN_ROOT + ? path.resolve(process.env.CODEX_SECURITY_TEST_PLUGIN_ROOT) + : path.resolve(applicationRoot, "../../../sdk/typescript/_bundled_plugin"); +const root = await temporaryDirectory("codex-security-finding-issues-mcp-"); +const scanDirectory = path.join(root, "scan"); +const stateDirectory = path.join(root, "state"); +const bundle = path.join(root, "server.cjs"); +const client = new Client({ name: "finding-issues-test", version: "1.0.0" }); + +try { + await cp(path.join(pluginRoot, "examples", "completed-scan"), scanDirectory, { + recursive: true, + }); + await buildServer(bundle, { target: "node20" }); + await client.connect( + new StdioClientTransport({ + command: process.execPath, + args: [bundle, "--stdio"], + cwd: applicationRoot, + env: { + ...process.env, + CODEX_SECURITY_PLUGIN_ROOT: runtimePluginRoot, + CODEX_SECURITY_STATE_DIR: stateDirectory, + } as Record, + }), + ); + const findingsPath = path.join(scanDirectory, "findings.json"); + const findingsBytes = await readFile(findingsPath, "utf8"); + const { findingId, occurrenceId } = JSON.parse(findingsBytes).findings[0]; + const manifestBytes = await readFile( + path.join(scanDirectory, "scan-manifest.json"), + ); + const source = { scanDirectory, findingIds: [findingId] }; + const jira = { type: "jira", cloudId: "example-site", projectId: "10001" }; + const linear = { type: "linear", teamId: "example-team" }; + const receipt = { + findingId, + occurrenceId, + issueIdentifier: "APP-1", + operation: "create", + }; + + const tools = (await client.listTools()).tools; + for (const name of [ + "get_codex_security_finding_issues", + "record_codex_security_finding_issues", + ]) { + const tool = tools.find((entry) => entry.name === name); + assert.ok(tool, `Missing model-visible tool ${name}`); + assert.deepEqual(tool._meta?.ui, { visibility: ["model"] }); + assert.equal( + Object.hasOwn(tool.inputSchema.properties ?? {}, "requireHistory"), + false, + ); + } + const empty = await call("get_codex_security_finding_issues", { + ...source, + destination: jira, + }); + assert.deepEqual(empty.receipts, []); + await assert.rejects(stat(path.join(stateDirectory, "workbench.sqlite3")), { + code: "ENOENT", + }); + + const accepted = await call("record_codex_security_finding_issues", { + ...source, + destination: jira, + receipts: [receipt], + }); + assert.equal(accepted.receipts.length, 1); + assert.equal(accepted.receipts[0].issueIdentifier, "APP-1"); + assert.equal(accepted.receipts[0].readback, undefined); + const readback = { status: "failed", error: "Issue read access denied." }; + await call("record_codex_security_finding_issues", { + ...source, + destination: jira, + receipts: [{ ...receipt, readback }], + }); + const saved = await call("get_codex_security_finding_issues", { + ...source, + destination: jira, + }); + assert.equal( + saved.receipts.length, + 1, + "Readback must update the accepted receipt.", + ); + assert.equal(saved.receipts[0].operation, "create"); + assert.deepEqual(saved.receipts[0].readback, readback); + assert.deepEqual( + ( + await call("get_codex_security_finding_issues", { + ...source, + destination: { ...jira, cloudId: "another-site" }, + }) + ).receipts, + [], + ); + + await call("record_codex_security_finding_issues", { + ...source, + destination: linear, + receipts: [ + { ...receipt, operation: "reuse", readback: { status: "verified" } }, + ], + }); + const reused = await call("get_codex_security_finding_issues", { + ...source, + destination: linear, + }); + assert.equal(reused.receipts.length, 1); + assert.equal(reused.receipts[0].operation, "reuse"); + assert.deepEqual(reused.receipts[0].readback, { status: "verified" }); + assert.deepEqual( + await readFile(path.join(scanDirectory, "scan-manifest.json")), + manifestBytes, + ); + assert.equal(await readFile(findingsPath, "utf8"), findingsBytes); + + await writeFile(findingsPath, findingsBytes + "\n"); + for (const [name, input] of [ + ["get_codex_security_finding_issues", { ...source, destination: jira }], + [ + "record_codex_security_finding_issues", + { ...source, destination: jira, receipts: [receipt] }, + ], + ] as const) { + const result = await client.callTool({ name, arguments: input }); + assert.equal( + result.isError, + true, + "Changed sealed artifacts must fail before association access.", + ); + assert.match( + (result.content as TextContent[])[0].text, + /sealed artifact changed/, + ); + } +} finally { + await client.close(); + await rm(root, { recursive: true, force: true }); +} + +async function call(name: string, input: Record) { + const result = await client.callTool({ name, arguments: input }); + assert.notEqual( + result.isError, + true, + (result.content as TextContent[])[0]?.text, + ); + return result.structuredContent as { + receipts: { + issueIdentifier: string; + operation: string; + readback?: { status: string; error?: string }; + }[]; + }; +} diff --git a/plugins/codex-security/mcp-app/tests/test_workbench_database.ts b/plugins/codex-security/mcp-app/tests/test_workbench_database.ts index 6f7b777184..2a6a75d0f9 100644 --- a/plugins/codex-security/mcp-app/tests/test_workbench_database.ts +++ b/plugins/codex-security/mcp-app/tests/test_workbench_database.ts @@ -99,7 +99,7 @@ test("opens a private WAL database at the configured state path", async () => { assert.equal( database.prepare("SELECT COUNT(*) AS count FROM schema_migrations").get() ?.count, - 42, + migrations.length, ); assert.deepEqual(database.prepare("PRAGMA foreign_key_check").all(), []); if (process.platform !== "win32") { @@ -129,7 +129,7 @@ test("every released schema upgrades to the same current schema and remains idem const current = memory(t); applyMigrations(current); const expected = schema(current); - for (let version = 0; version <= 42; version++) { + for (let version = 0; version <= migrations.length; version++) { const database = memory(t, version); applyMigrations(database); assert.deepEqual( @@ -243,7 +243,7 @@ test( database .prepare("SELECT MAX(version) AS version FROM schema_migrations") .get()?.version, - 42, + migrations.length, ); } finally { database.close(); @@ -721,7 +721,7 @@ test("retries an upgrade when another process holds the write lock beyond the bu database .prepare("SELECT COUNT(*) AS count FROM schema_migrations") .get()?.count, - 42, + migrations.length, ); assert.equal( database.prepare("SELECT COUNT(*) AS count FROM security_targets").get() diff --git a/plugins/codex-security/mcp-app/tests/test_workbench_state_fallback.ts b/plugins/codex-security/mcp-app/tests/test_workbench_state_fallback.ts index 01bdf1792e..3e333a8f3a 100644 --- a/plugins/codex-security/mcp-app/tests/test_workbench_state_fallback.ts +++ b/plugins/codex-security/mcp-app/tests/test_workbench_state_fallback.ts @@ -385,6 +385,117 @@ async function testWorkbenchStateFallback() { await inspectionFirstServer.stop(); } + const scanDirectory = path.join(pluginRoot, "examples", "completed-scan"); + const { findingId, occurrenceId } = JSON.parse( + await readFile(path.join(scanDirectory, "findings.json"), "utf8"), + ).findings[0]; + const issueInput = { + scanDirectory, + destination: { type: "linear", teamId: "example-team" }, + }; + const issueReceipt = { + findingId, + occurrenceId, + issueIdentifier: "APP-1", + operation: "create", + }; + for (const existingStore of ["missing", "empty", "populated"]) { + await writeFile(invocationLog, ""); + const issueScanRoot = path.join( + fixtureRoot, + `issue-${existingStore}-scans`, + ); + const issueHome = path.join(fixtureRoot, `issue-${existingStore}-home`); + const issueState = path.join( + issueHome, + "state", + "plugins", + "codex-security", + ); + const receipts = existingStore === "populated" ? [issueReceipt] : []; + if (existingStore !== "missing") { + execFileSync( + realPython, + [ + path.join(pluginRoot, "scripts", "workbench_db.py"), + "finding-issues", + ], + { + env: { ...process.env, CODEX_SECURITY_STATE_DIR: issueState }, + input: JSON.stringify({ + ...issueInput, + action: "record", + receipts, + }), + }, + ); + } + const issueServer = startServer(serverBundlePath, { + CODEX_HOME: issueHome, + CODEX_SECURITY_SCAN_ROOT: issueScanRoot, + CODEX_SECURITY_STATE_DIR: undefined, + FAKE_PYTHON_ALWAYS_FAIL: undefined, + FAKE_PYTHON_FAILURE: + "sqlite3.OperationalError: unable to open database file", + FAKE_PYTHON_LOG: invocationLog, + FAKE_PYTHON_PERSISTENT_SUCCESSES: "1", + FAKE_REAL_PYTHON: realPython, + PYTHON: fakePythonPath, + }); + try { + await initialize(issueServer, 1); + const inspected = await issueServer.request(2, "tools/call", { + name: "get_codex_security_finding_issues", + arguments: issueInput, + }); + assertNoError(inspected); + assert.equal( + inspected.result.structuredContent.storeExists, + existingStore !== "missing", + ); + assert.equal( + inspected.result.structuredContent.receipts.length, + receipts.length, + ); + const recorded = await issueServer.request(3, "tools/call", { + name: "record_codex_security_finding_issues", + arguments: { ...issueInput, receipts: [issueReceipt] }, + }); + const fallbackStateDir = path.join(issueScanRoot, "workbench-state"); + if (existingStore === "missing") { + assertNoError(recorded); + const saved = await issueServer.request(4, "tools/call", { + name: "get_codex_security_finding_issues", + arguments: issueInput, + }); + assertNoError(saved); + assert.equal( + saved.result.structuredContent.receipts[0].issueIdentifier, + "APP-1", + ); + assert.deepEqual( + (await readJsonLines(invocationLog)).map((entry) => entry.stateDir), + [null, null, fallbackStateDir, fallbackStateDir], + ); + } else { + assertToolError(recorded, /unable to open database file/); + assert.deepEqual( + (await readJsonLines(invocationLog)).map((entry) => entry.stateDir), + [null, null], + ); + assert.equal(await pathExists(fallbackStateDir), false); + } + assert.equal( + issueServer + .stderrEvents() + .filter((event) => event.event === "state_fallback_pinned").length, + existingStore === "missing" ? 1 : 0, + ); + } finally { + await issueServer.stop(); + } + } + await writeFile(invocationLog, ""); const provenScanRoot = path.join(fixtureRoot, "proven-scans"); const provenServer = startServer(serverBundlePath, { diff --git a/plugins/codex-security/scripts/workbench_cli.py b/plugins/codex-security/scripts/workbench_cli.py index 1d01c0d283..96b6b442da 100644 --- a/plugins/codex-security/scripts/workbench_cli.py +++ b/plugins/codex-security/scripts/workbench_cli.py @@ -372,13 +372,7 @@ def parse_args(description: str) -> argparse.Namespace: export_findings.add_argument("--format", choices=(*EXPORT_FORMATS, "md")) export_findings.add_argument("--validate-only", action="store_true") - for command in ( - "inspect-linear-publication", - "prepare-linear-publication", - "record-linear-publications", - ): - publication = subparsers.add_parser(command) - publication.add_argument("--input-file", required=True) + subparsers.add_parser("finding-issues") subparsers.add_parser("database-info") subparsers.add_parser("finding-workflow") diff --git a/plugins/codex-security/scripts/workbench_db.py b/plugins/codex-security/scripts/workbench_db.py index 5968b470d6..518e543088 100644 --- a/plugins/codex-security/scripts/workbench_db.py +++ b/plugins/codex-security/scripts/workbench_db.py @@ -3266,8 +3266,8 @@ def main() -> None: result = severity.read_classification(database_path(), args.scan_id) print(json.dumps(result, allow_nan=False, sort_keys=True)) return - if args.command == "inspect-linear-publication": - result = publication.inspect_linear_publication(_WORKBENCH_PUBLICATION_CONTEXT, args) + if args.command == "finding-issues": + result = publication.finding_issues(_WORKBENCH_PUBLICATION_CONTEXT, json.load(sys.stdin)) print(json.dumps(result, allow_nan=False, sort_keys=True)) return with closing( @@ -3419,14 +3419,6 @@ def main() -> None: ) elif args.command == "set-finding-remediation": result = set_finding_remediation(connection, args) - elif args.command == "prepare-linear-publication": - result = publication.prepare_linear_publication( - _WORKBENCH_PUBLICATION_CONTEXT, connection, args - ) - elif args.command == "record-linear-publications": - result = publication.record_linear_publications( - _WORKBENCH_PUBLICATION_CONTEXT, connection, args - ) elif args.command == "export-findings": result = publication.export_findings(_WORKBENCH_PUBLICATION_CONTEXT, connection, args) elif args.command == "database-info": diff --git a/plugins/codex-security/scripts/workbench_publication.py b/plugins/codex-security/scripts/workbench_publication.py index 411f3f81a1..fe32965e94 100644 --- a/plugins/codex-security/scripts/workbench_publication.py +++ b/plugins/codex-security/scripts/workbench_publication.py @@ -1,10 +1,11 @@ -"""Validate, record, and export findings from completed scans.""" +"""Validate, record, and export findings from sealed scans.""" from __future__ import annotations import argparse import csv import io +import json import os import sqlite3 import sys @@ -26,90 +27,50 @@ write_export_output, write_sarif_projection, ) +from validate_scan_contract import validate_contract -def linear_publication_input( - db: Any, - args: argparse.Namespace, - *, - recording: bool, -) -> tuple[dict[str, Any], dict[str, str], list[dict[str, str]]]: - payload = db.read_json_object(Path(args.input_file)) - required = {"scanId", "scanDirectory", "destination", "findings"} - if recording: - required.add("publications") - if set(payload) != required: - raise SystemExit("Linear publication input contains unexpected or missing fields.") - - scan_id = payload["scanId"] - scan_directory = payload["scanDirectory"] - destination = payload["destination"] - findings = payload["findings"] - if not isinstance(scan_id, str) or not isinstance(scan_directory, str): - raise SystemExit("Linear publication input must identify the exact completed scan.") - if ( - not isinstance(destination, dict) - or not {"type", "teamId"}.issubset(destination) - or not set(destination).issubset({"type", "teamId", "projectId"}) - or destination.get("type") != "linear" - or not isinstance(destination.get("teamId"), str) - or not destination["teamId"].strip() - or ( - "projectId" in destination - and ( - not isinstance(destination["projectId"], str) - or not destination["projectId"].strip() - ) - ) - ): - raise SystemExit( - "Linear publication input must identify the exact team and optional project." - ) - if not isinstance(findings, list): - raise SystemExit("Linear publication input must include the planned scan findings.") - - seen_finding_ids: set[str] = set() - seen_occurrence_ids: set[str] = set() - for finding in findings: - if ( - not isinstance(finding, dict) - or set(finding) != {"findingId", "occurrenceId"} - or not isinstance(finding.get("findingId"), str) - or not finding["findingId"].strip() - or not isinstance(finding.get("occurrenceId"), str) - or not finding["occurrenceId"].strip() - ): - raise SystemExit("Linear publication input contains an invalid finding identity.") - if ( - finding["findingId"] in seen_finding_ids - or finding["occurrenceId"] in seen_occurrence_ids - ): - raise SystemExit("Linear publication input repeats a finding or occurrence.") - seen_finding_ids.add(finding["findingId"]) - seen_occurrence_ids.add(finding["occurrenceId"]) - - return payload, destination, findings +def destination_key(destination: Any) -> str: + if not isinstance(destination, dict): + raise SystemExit("Finding issues require an exact tracker destination.") + kind = destination.get("type") + if kind == "linear": + required, optional = {"type", "teamId"}, {"projectId"} + parts = [kind, destination.get("teamId"), destination.get("projectId")] + elif kind == "jira": + required, optional = {"type", "cloudId", "projectId"}, set() + parts = [kind, destination.get("cloudId"), destination.get("projectId")] + elif kind in {"github-issue", "github-advisory"}: + required, optional = {"type", "hostname", "repository"}, set() + parts = [kind, destination.get("hostname"), destination.get("repository")] + else: + raise SystemExit("Unsupported finding issue destination.") + if not required.issubset(destination) or not set(destination).issubset(required | optional): + raise SystemExit("Finding issues require the exact provider destination fields.") + if any(not isinstance(value, str) or not value.strip() for value in destination.values()): + raise SystemExit("Finding issue destination fields must be nonempty strings.") + return json.dumps(parts, ensure_ascii=False, separators=(",", ":")) -def verify_linear_publication_scan( +def verify_publication_history( db: Any, connection: sqlite3.Connection, - payload: dict[str, Any], - findings: list[dict[str, str]], -) -> sqlite3.Row: + scan_id: str, + scan_directory: str, + findings: list[dict[str, Any]], +) -> None: try: - scan = db.require_scan(connection, payload["scanId"]) + scan = db.require_scan(connection, scan_id) except SystemExit as exc: raise SystemExit( "The completed scan is not present in the local Codex Security scan-history database. " "Use the state directory where the scan was completed." ) from exc - if scan["id"] != payload["scanId"]: - raise SystemExit("Linear publication must use the exact completed scan identifier.") + if scan["id"] != scan_id: + raise SystemExit("Publication must use the exact completed scan identifier.") if scan["status"] != "complete": raise SystemExit("Only completed scans can publish findings to Linear.") - - requested_directory = db.require_canonical_scan_directory(Path(payload["scanDirectory"])) + requested_directory = db.require_canonical_scan_directory(Path(scan_directory)) recorded_directory = db.require_canonical_scan_directory(Path(scan["scan_dir"])) if os.path.normcase(requested_directory) != os.path.normcase(recorded_directory): raise SystemExit( @@ -117,219 +78,202 @@ def verify_linear_publication_scan( ) if "seal_manifest_digest" in scan.keys(): db.require_recorded_manifest_digest(scan, recorded_directory) - stored_findings = { row["id"]: row["finding_id"] for row in connection.execute( - "SELECT id, finding_id FROM finding_occurrences WHERE scan_id = ?", - (scan["id"],), + "SELECT id, finding_id FROM finding_occurrences WHERE scan_id = ?", (scan_id,) ) } - for finding in findings: - if stored_findings.get(finding["occurrenceId"]) != finding["findingId"]: - raise SystemExit( - "A selected finding or occurrence does not belong to the completed scan " - "in local Codex Security scan history." - ) - if len(stored_findings) != len(findings): + expected = {finding["occurrenceId"]: finding["findingId"] for finding in findings} + if stored_findings != expected: raise SystemExit( "The completed scan findings do not exactly match local Codex Security scan history." ) - return scan -def inspect_linear_publication( - db: Any, - args: argparse.Namespace, -) -> dict[str, Any]: - payload, destination, findings = linear_publication_input(db, args, recording=False) - database_uri = f"file:{quote(str(db.database_path()), safe='')}?mode=ro" - with closing(sqlite3.connect(database_uri, uri=True, timeout=5)) as connection: - connection.row_factory = sqlite3.Row - connection.execute("BEGIN") - scan = verify_linear_publication_scan(db, 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 issue_receipt(row: sqlite3.Row) -> dict[str, Any]: + return { + "scanId": row["scan_id"], + "findingId": row["finding_id"], + "occurrenceId": row["occurrence_id"], + "issueIdentifier": row["external_id"], + "operation": row["operation"], + **({"url": row["external_url"]} if row["external_url"] is not None else {}), + **({"readback": json.loads(row["readback_json"])} if row["readback_json"] else {}), + } -def prepare_linear_publication( - db: Any, +def inspect_issue_receipts( connection: sqlite3.Connection, - args: argparse.Namespace, -) -> dict[str, Any]: - payload, destination, findings = linear_publication_input(db, args, recording=False) - connection.execute("BEGIN IMMEDIATE") - with connection: - scan = verify_linear_publication_scan(db, connection, payload, findings) - result = { - "scanId": scan["id"], - "destination": destination, - "findingCount": len(findings), - } - return result + destination: dict[str, str], + key: str, + finding_ids: list[str], +) -> list[dict[str, Any]]: + tables = { + row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type = 'table'") + } + selection = json.dumps(finding_ids) + if "finding_issue_receipts" in tables: + rows = connection.execute( + "SELECT * FROM finding_issue_receipts WHERE destination_key = ? " + "AND finding_id IN (SELECT value FROM json_each(?)) ORDER BY id", + (key, selection), + ) + elif "finding_publications" in tables and destination["type"] == "linear": + rows = connection.execute( + "SELECT *, 'create' AS operation, NULL AS readback_json FROM finding_publications " + "WHERE destination_type = 'linear' AND team_id = ? AND project_id IS ? " + "AND finding_id IN (SELECT value FROM json_each(?)) ORDER BY id", + (destination["teamId"], destination.get("projectId"), selection), + ) + else: + return [] + return [issue_receipt(row) for row in rows] -def record_linear_publications( - db: Any, - connection: sqlite3.Connection, - args: argparse.Namespace, -) -> dict[str, Any]: - payload, destination, findings = linear_publication_input(db, args, recording=True) - publications = payload["publications"] - if not isinstance(publications, list): - raise SystemExit("Linear publication results must be an array.") - planned = {finding["findingId"]: finding["occurrenceId"] for finding in findings} - current: dict[str, dict[str, str]] = {} - external_ids: set[str] = set() - for publication in publications: - if ( - not isinstance(publication, dict) - or not {"findingId", "occurrenceId", "issueIdentifier"}.issubset(publication) - or not set(publication).issubset( - {"findingId", "occurrenceId", "issueIdentifier", "url"} - ) - or not isinstance(publication.get("findingId"), str) - or not isinstance(publication.get("occurrenceId"), str) - or not isinstance(publication.get("issueIdentifier"), str) - or not publication["issueIdentifier"].strip() - or ( - "url" in publication - and (not isinstance(publication["url"], str) or not publication["url"].strip()) - ) +def validate_issue_receipts(receipts: Any, selected: dict[str, str]) -> list[dict[str, Any]]: + if not isinstance(receipts, list): + raise SystemExit("Finding issue receipts must be an array.") + for receipt in receipts: + if not isinstance(receipt, dict): + raise SystemExit("Each finding issue receipt must be an object.") + if any( + not isinstance(receipt.get(field), str) or not receipt[field].strip() + for field in ("findingId", "occurrenceId", "issueIdentifier") ): - raise SystemExit("Linear publication results contain an invalid issue association.") - finding_id = publication["findingId"] - issue_identifier = publication["issueIdentifier"] - if planned.get(finding_id) != publication["occurrenceId"]: + raise SystemExit("A finding issue receipt is missing its source or issue identity.") + if selected.get(receipt["findingId"]) != receipt["occurrenceId"]: raise SystemExit( - "A created Linear issue does not match its planned finding and occurrence." + "A finding issue receipt does not belong to the selected sealed findings." ) - if finding_id in current or issue_identifier in external_ids: - raise SystemExit("Linear publication results repeat a finding or issue identifier.") - current[finding_id] = publication - external_ids.add(issue_identifier) - - connection.execute("BEGIN IMMEDIATE") - with connection: - scan = verify_linear_publication_scan(db, connection, payload, findings) - timestamp = db.now() - for publication in publications: - conflicting = connection.execute( - """ - SELECT occurrence_id, external_url - FROM finding_publications - WHERE destination_type = ? AND team_id = ? AND project_id IS ? - AND external_id = ? - """, - ( - destination["type"], - destination["teamId"], - destination.get("projectId"), - publication["issueIdentifier"], - ), - ).fetchone() - if ( - conflicting is not None - and conflicting["occurrence_id"] != publication["occurrenceId"] - ): - raise SystemExit( - "This Linear issue is already associated with a different finding." - ) + if receipt.get("operation") not in {"create", "update", "reuse"}: + raise SystemExit("A finding issue receipt requires create, update, or reuse.") + if "url" in receipt and (not isinstance(receipt["url"], str) or not receipt["url"].strip()): + raise SystemExit("A finding issue URL must be a nonempty string when supplied.") + if "readback" in receipt: + readback = receipt["readback"] if ( - conflicting is not None - and "url" in publication - and conflicting["external_url"] != publication["url"] + not isinstance(readback, dict) + or readback.get("status") not in {"verified", "failed"} + or ("error" in readback and not isinstance(readback["error"], str)) ): - raise SystemExit("This Linear issue is already associated with a different URL.") + raise SystemExit("A finding issue readback requires verified or failed status.") + return receipts - connection.execute( - """ - INSERT INTO finding_publications ( - scan_id, finding_id, occurrence_id, destination_type, - team_id, project_id, external_id, external_url, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT DO NOTHING - """, - ( - scan["id"], - publication["findingId"], - publication["occurrenceId"], - destination["type"], - destination["teamId"], - destination.get("projectId"), - publication["issueIdentifier"], - publication.get("url"), - timestamp, - ), - ) - created = [] - for finding in findings: - publication = current.get(finding["findingId"]) - if publication is None: - continue - row = connection.execute( - """ - SELECT finding_id, occurrence_id, external_id, external_url - FROM finding_publications - WHERE scan_id = ? AND occurrence_id = ? AND destination_type = ? - AND team_id = ? AND project_id IS ? AND external_id = ? - """, - ( - scan["id"], - publication["occurrenceId"], - destination["type"], - destination["teamId"], - destination.get("projectId"), - publication["issueIdentifier"], - ), - ).fetchone() - if row is None: - raise SystemExit("A created Linear issue could not be read from scan history.") - created.append( - { - "findingId": row["finding_id"], - "occurrenceId": row["occurrence_id"], - "issueIdentifier": row["external_id"], - **({"url": row["external_url"]} if row["external_url"] is not None else {}), - } +def finding_issues(db: Any, payload: Any) -> dict[str, Any]: + if not isinstance(payload, dict) or payload.get("action") not in { + "inspect", + "prepare", + "record", + }: + raise SystemExit("Finding issues require inspect, prepare, or record action.") + if not isinstance(payload.get("scanDirectory"), str): + raise SystemExit("Finding issues require a sealed scan directory.") + require_history = payload.get("requireHistory", False) + if not isinstance(require_history, bool): + raise SystemExit("requireHistory must be a boolean.") + key = destination_key(payload.get("destination")) + try: + validated = validate_contract(Path(payload["scanDirectory"])) + except (OSError, ValueError, RecursionError) as exc: + raise SystemExit(f"Finding issue source validation failed: {exc}") from exc + scan_id = validated["manifest"]["scan"]["id"] + if "expectedScanId" in payload and payload["expectedScanId"] != scan_id: + raise SystemExit("Scan artifacts do not match the selected scan identifier.") + findings = validated["findings"]["findings"] + available = {finding["findingId"]: finding["occurrenceId"] for finding in findings} + finding_ids = payload.get("findingIds", list(available)) + if not isinstance(finding_ids, list) or any( + not isinstance(finding_id, str) or finding_id not in available for finding_id in finding_ids + ): + raise SystemExit("Selected finding IDs must belong to the sealed scan.") + selected = {finding_id: available[finding_id] for finding_id in finding_ids} + if require_history and selected != available: + raise SystemExit( + "The completed scan findings do not exactly match local Codex Security scan history." + ) + receipts = ( + validate_issue_receipts(payload.get("receipts"), selected) + if payload["action"] == "record" + else [] + ) + result = { + "scanId": scan_id, + "destination": payload["destination"], + "findingCount": len(selected), + "storeExists": True, + "receipts": [], + } + path = db.database_path() + if not path.is_file(): + if require_history: + raise SystemExit( + "Cannot publish findings because the local Codex Security scan-history database " + "does not exist. Use the state directory where this scan was completed." ) - result = {"scanId": scan["id"], "destination": destination, "created": created} + if payload["action"] == "inspect": + result["storeExists"] = False + return result + if payload["action"] == "inspect": + database_uri = f"file:{quote(str(path), safe='')}?mode=ro" + connection = sqlite3.connect(database_uri, uri=True, timeout=5) + connection.row_factory = sqlite3.Row + else: + connection = db.connect() + with closing(connection): + connection.execute("BEGIN" if payload["action"] == "inspect" else "BEGIN IMMEDIATE") + with connection: + if require_history: + verify_publication_history(db, connection, scan_id, validated["scanDir"], findings) + if payload["action"] == "inspect": + result["receipts"] = inspect_issue_receipts( + connection, payload["destination"], key, list(selected) + ) + elif payload["action"] == "record": + timestamp = db.now() + for receipt in receipts: + readback = ( + json.dumps(receipt["readback"], ensure_ascii=False) + if "readback" in receipt + else None + ) + connection.execute( + """ + INSERT INTO finding_issue_receipts ( + scan_id, finding_id, occurrence_id, destination_key, + external_id, external_url, operation, readback_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (occurrence_id, destination_key, external_id, operation) + DO UPDATE SET + external_url = COALESCE(excluded.external_url, external_url), + readback_json = COALESCE(excluded.readback_json, readback_json), + updated_at = excluded.updated_at + """, + ( + scan_id, + receipt["findingId"], + receipt["occurrenceId"], + key, + receipt["issueIdentifier"], + receipt.get("url"), + receipt["operation"], + readback, + timestamp, + timestamp, + ), + ) + row = connection.execute( + "SELECT * FROM finding_issue_receipts WHERE occurrence_id = ? " + "AND destination_key = ? AND external_id = ? AND operation = ?", + ( + receipt["occurrenceId"], + key, + receipt["issueIdentifier"], + receipt["operation"], + ), + ).fetchone() + result["receipts"].append(issue_receipt(row)) return result diff --git a/plugins/codex-security/shared/workbench-migrations.json b/plugins/codex-security/shared/workbench-migrations.json index 0657ebfd22..bc0b8b17f5 100644 --- a/plugins/codex-security/shared/workbench-migrations.json +++ b/plugins/codex-security/shared/workbench-migrations.json @@ -357,5 +357,15 @@ "version": 42, "name": "editable scan names", "statements": ["ALTER TABLE scans ADD COLUMN name TEXT;"] + }, + { + "version": 43, + "name": "share finding issue receipts across trackers", + "statements": [ + "CREATE TABLE finding_issue_receipts (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n scan_id TEXT NOT NULL,\n finding_id TEXT NOT NULL,\n occurrence_id TEXT NOT NULL,\n destination_key TEXT NOT NULL,\n external_id TEXT NOT NULL,\n external_url TEXT,\n operation TEXT NOT NULL CHECK (operation IN ('create', 'update', 'reuse')),\n readback_json TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n UNIQUE (occurrence_id, destination_key, external_id, operation)\n);", + "INSERT INTO finding_issue_receipts (\n scan_id, finding_id, occurrence_id, destination_key, external_id, external_url,\n operation, readback_json, created_at, updated_at\n) SELECT scan_id, finding_id, occurrence_id, json_array(destination_type, team_id, project_id),\n external_id, external_url, 'create', NULL, created_at, created_at\nFROM finding_publications ORDER BY id;", + "DROP TABLE finding_publications;", + "CREATE INDEX finding_issue_receipts_by_finding\nON finding_issue_receipts(finding_id, destination_key, id);" + ] } ] diff --git a/plugins/codex-security/skills/track-findings/SKILL.md b/plugins/codex-security/skills/track-findings/SKILL.md index d51663a5fe..1cd134dca5 100644 --- a/plugins/codex-security/skills/track-findings/SKILL.md +++ b/plugins/codex-security/skills/track-findings/SKILL.md @@ -52,12 +52,14 @@ Report source status as `verified`, `unverified` (a candidate cannot be tied to Read the provider state needed to choose supported fields. Use exact ids for teams, projects, labels, milestones, and assignees when available. Do not invent metadata or infer it from names. +Call `get_codex_security_finding_issues` with `scanDirectory`, the selected `findingIds`, and the confirmed `destination`: Linear `{type: "linear", teamId, projectId?}`, Jira `{type: "jira", cloudId, projectId}`, or GitHub `{type: "github-issue" | "github-advisory", hostname, repository: "owner/repo"}`. Use canonical provider ids. Saved associations include earlier scan occurrences and CLI publications; read them through the selected provider and confirm the match before reuse. They do not replace remote duplicate searches. + Search the finding id and fingerprint before proposing a create. Search all statuses in the exact destination; GitHub issue searches exclude pull requests. Complete identifier searches and read plausible matches. Add narrow semantic searches when safe for the confirmed audience. Compare bindings when present and assess whether the source context, affected code, root cause, control, and sink match. Similar wording or missing bindings alone do not settle the comparison. Choose one outcome: - `create`: complete searches found no issue tracking the same finding. -- `reuse`: one issue tracks the finding, established by a current read or this run's successful create receipt. Reuse is read-only and does not require adding bindings or rewriting the issue. +- `reuse`: one issue tracks the finding, established by a current read or this run's successful create receipt. Reuse does not require adding bindings or rewriting the issue. - `update`: one verified matching issue should receive specific reviewed changes. - `blocked`: routing, access, disclosure, or duplicate ambiguity prevents a decision. @@ -73,7 +75,7 @@ Every create or update body includes labeled canonical finding id and primary fi Do not include credentials, signed URLs, local file URLs, or unreviewed links. A public GitHub issue requires approval of its complete public title and body, including a prominent visibility warning. Exclude internal evidence, attack paths, exploit detail, and private source links from public issues. -For batches, show every item in execution order and obtain approval for that list. A general request to track findings does not approve an unseen payload. Changes to source, identity, transport, destination, audience, duplicate decision, payload, or membership require a new preview and approval. Read-only reuse requires neither mutation approval nor write access. +For batches, show every item in execution order and obtain approval for that list. A general request to track findings does not approve an unseen payload. Changes to source, identity, transport, destination, audience, duplicate decision, payload, or membership require a new preview and approval. Reuse requires neither provider mutation approval nor write access. ## 5. Apply and verify @@ -81,9 +83,11 @@ After an approval pause or interruption, rerun source validation, reverify sourc Before creating, refresh duplicate results to catch an issue created while awaiting approval. After a pause, reread each selected reuse candidate and reconfirm the match; stop if it is unreadable or ambiguous, and return to preview if the duplicate decision changes. Before updating after a pause, reread the fields being changed and return to preview if they differ from the reviewed values. Without an intervening pause, a successful create receipt can supply the values for an already approved follow-up edit. Preserve unowned fields and existing rich content. -Process findings serially in the approved order. Immediately before each create, update, or reuse, run the source validator with that exact finding id and stop if it fails. Use the exact approved payload and record each provider receipt outside the sealed bundle. Stop the batch on a failed or uncertain mutation or unresolved duplicate. +Process findings serially in the approved order. Immediately before each create, update, or reuse, run the source validator with that exact finding id and stop if it fails. Use the exact approved payload. Stop the batch on a failed or uncertain mutation or unresolved duplicate. + +A successful provider response establishes an accepted write. Immediately call `record_codex_security_finding_issues` with the same source and destination and a receipt containing the canonical `findingId` and `occurrenceId`, returned `issueIdentifier`, optional returned `url`, and `operation: "create" | "update" | "reuse"`. Record verified reuse the same way, using the current scan occurrence. Preserve returned identities and stop the batch if local recording fails; a storage failure never authorizes another create. -A successful provider response establishes an accepted write. Read the returned object through the same transport when possible to check identity and changed fields, allowing provider formatting that preserves meaning and links. Report a failed follow-up read separately, retaining the returned identity. An unreadable issue does not authorize another create. +After recording an accepted write, read the returned object through the same transport when possible to check identity and changed fields, allowing provider formatting that preserves meaning and links. Record the same receipt again with `readback: {status: "verified"}` or `{status: "failed", error}`; omit readback when no read was attempted. A current read confirming reuse can be recorded as verified immediately. Keep accepted writes and read failures distinct. An unreadable issue does not authorize another create. For CLI issue writes, put the approved body in a mode-`0600` temporary file outside the repository and scan bundle, arrange cleanup on every exit, and pass it to one `gh issue create` or `gh issue edit`. Never print the file. Capture the issue identity and read it with `gh issue view --json`. Use the advisory reference for JSON file creation and required readback. diff --git a/plugins/codex-security/skills/track-findings/references/github-security-advisories.md b/plugins/codex-security/skills/track-findings/references/github-security-advisories.md index 49bb74f4d0..f5b117520d 100644 --- a/plugins/codex-security/skills/track-findings/references/github-security-advisories.md +++ b/plugins/codex-security/skills/track-findings/references/github-security-advisories.md @@ -43,4 +43,4 @@ Use the main skill's preview and approval flow, including package metadata, the Write the approved JSON to a mode-`0600` temporary file outside the repository and scan directory. Review it for sensitive content and send it once with `gh api --hostname github.com --input`; remove it on every exit. Reconcile uncertain creates by exact bindings before considering another mutation. -Read the returned `ghsa_id` through the allowed endpoint. Compare normalized structured readback with the approved payload and require `state: draft` before reporting success. +Record the accepted create with the returned `ghsa_id` using the main skill's shared issue history, then read it through the allowed endpoint. Compare normalized structured readback with the approved payload and require `state: draft` before reporting success. Record that readback separately from the accepted create. diff --git a/plugins/codex-security/tests/test_finding_issue_receipts.py b/plugins/codex-security/tests/test_finding_issue_receipts.py new file mode 100644 index 0000000000..5159f135bc --- /dev/null +++ b/plugins/codex-security/tests/test_finding_issue_receipts.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +import json +import shutil +import sqlite3 +import uuid +from contextlib import closing +from pathlib import Path + +import pytest +from workbench_test_support import load_script, run_workbench, write_completed_contract + +PLUGIN_ROOT = Path(__file__).resolve().parents[1] +VALIDATOR = load_script("validate_scan_contract") +SCHEMA = load_script("workbench_schema") +LINEAR = {"type": "linear", "teamId": "team-example"} +JIRA = {"type": "jira", "cloudId": "site-example", "projectId": "10001"} + + +def source(tmp_path: Path) -> tuple[Path, dict]: + scan_dir = tmp_path / "scan" + shutil.copytree(PLUGIN_ROOT / "examples" / "completed-scan", scan_dir) + return scan_dir, VALIDATOR.validate_contract(scan_dir) + + +def receipt_for(validated: dict, **fields) -> dict: + finding = validated["findings"]["findings"][0] + return { + "findingId": finding["findingId"], + "occurrenceId": finding["occurrenceId"], + "issueIdentifier": "SEC-101", + "operation": "create", + **fields, + } + + +def issues(state: Path, scan_dir: Path, action: str, *, destination=None, **fields) -> dict: + return run_workbench( + state, + "finding-issues", + input_text=json.dumps( + { + "action": action, + "scanDirectory": str(scan_dir), + "destination": destination or LINEAR, + **fields, + } + ), + ) + + +def legacy_database(path: Path, validated: dict, destination: dict) -> None: + path.parent.mkdir() + with closing(sqlite3.connect(path)) as connection: + connection.row_factory = sqlite3.Row + SCHEMA.apply_migrations( + connection, SCHEMA.MIGRATIONS[:-1], lambda: "2026-01-01T00:00:00Z", lambda _: None + ) + receipt = receipt_for(validated) + connection.execute( + "INSERT INTO finding_publications (scan_id, finding_id, occurrence_id, destination_type, " + "team_id, project_id, external_id, external_url, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + validated["manifest"]["scan"]["id"], + receipt["findingId"], + receipt["occurrenceId"], + "linear", + destination["teamId"], + destination.get("projectId"), + "SEC-101", + "https://linear.app/example/issue/SEC-101", + "2026-01-01T00:00:00Z", + ), + ) + connection.commit() + + +def test_inspection_of_portable_source_does_not_create_state(tmp_path: Path) -> None: + scan_dir, validated = source(tmp_path) + state = tmp_path / "state" + result = issues(state, scan_dir, "inspect") + assert result["scanId"] == validated["manifest"]["scan"]["id"] + assert result["receipts"] == [] + assert result["storeExists"] is False + assert not state.exists() + + +@pytest.mark.parametrize("project", [None, "project-example"]) +def test_legacy_receipts_are_read_without_migration_and_preserved_on_upgrade( + tmp_path: Path, project: str | None +) -> None: + scan_dir, validated = source(tmp_path) + destination = {"type": "linear", "teamId": "équipe-example"} + if project is not None: + destination["projectId"] = project + state = tmp_path / "state" + path = state / "workbench.sqlite3" + legacy_database(path, validated, destination) + original = path.read_bytes() + inspected = issues(state, scan_dir, "inspect", destination=destination) + assert inspected["storeExists"] is True + assert inspected["receipts"] == [ + { + **receipt_for(validated), + "scanId": validated["manifest"]["scan"]["id"], + "url": "https://linear.app/example/issue/SEC-101", + } + ] + assert path.read_bytes() == original + prepared = issues(state, scan_dir, "prepare", destination=destination) + assert prepared["receipts"] == [] + assert prepared["storeExists"] is True + assert issues(state, scan_dir, "inspect", destination=destination) == inspected + with closing(sqlite3.connect(path)) as connection: + assert connection.execute("SELECT MAX(version) FROM schema_migrations").fetchone()[0] == 43 + assert ( + connection.execute( + "SELECT 1 FROM sqlite_master WHERE name = 'finding_publications'" + ).fetchone() + is None + ) + key = connection.execute("SELECT destination_key FROM finding_issue_receipts").fetchone()[0] + assert key == json.dumps( + ["linear", "équipe-example", project], ensure_ascii=False, separators=(",", ":") + ) + + +def test_records_id_only_acceptance_and_failed_readback_without_losing_create( + tmp_path: Path, +) -> None: + scan_dir, validated = source(tmp_path) + state = tmp_path / "state" + accepted = receipt_for(validated) + recorded = issues(state, scan_dir, "record", destination=JIRA, receipts=[accepted]) + assert recorded["storeExists"] is True + assert recorded["receipts"] == [{**accepted, "scanId": validated["manifest"]["scan"]["id"]}] + failed_read = {"status": "failed", "error": "Read denied; diagnostic sk-synthetic-value"} + issues( + state, + scan_dir, + "record", + destination=JIRA, + receipts=[ + { + **accepted, + "url": "https://issues.example.test/browse/SEC-101", + "readback": failed_read, + } + ], + ) + issues(state, scan_dir, "record", destination=JIRA, receipts=[accepted]) + issues( + state, + scan_dir, + "record", + destination=JIRA, + receipts=[ + { + **accepted, + "operation": "reuse", + "readback": {"status": "verified"}, + } + ], + ) + history = issues(state, scan_dir, "inspect", destination=JIRA)["receipts"] + assert len(history) == 2 + assert history[0]["operation"] == "create" + assert history[0]["url"] == "https://issues.example.test/browse/SEC-101" + assert history[0]["readback"] == failed_read + assert history[1]["operation"] == "reuse" + assert history[1]["readback"] == {"status": "verified"} + with closing(sqlite3.connect(state / "workbench.sqlite3")) as connection: + assert connection.execute("SELECT COUNT(*) FROM scans").fetchone()[0] == 0 + + +@pytest.mark.parametrize( + "destination", + [ + {"type": "linear", "teamId": "different-team"}, + {"type": "linear", "teamId": "team-example", "projectId": "project-example"}, + JIRA, + {"type": "github-issue", "hostname": "github.com", "repository": "example/repo"}, + {"type": "github-advisory", "hostname": "github.com", "repository": "example/repo"}, + ], +) +def test_destination_scope_is_part_of_receipt_identity(tmp_path: Path, destination: dict) -> None: + scan_dir, validated = source(tmp_path) + state = tmp_path / "state" + accepted = receipt_for(validated) + issues(state, scan_dir, "record", receipts=[accepted]) + assert issues(state, scan_dir, "inspect", destination=destination)["receipts"] == [] + issues(state, scan_dir, "record", destination=destination, receipts=[accepted]) + assert len(issues(state, scan_dir, "inspect", destination=destination)["receipts"]) == 1 + assert len(issues(state, scan_dir, "inspect")["receipts"]) == 1 + + +def test_same_issue_can_track_a_later_occurrence(tmp_path: Path) -> None: + target = tmp_path / "target" + target.mkdir() + state = tmp_path / "state" + sources = [] + for index in range(2): + scan_dir = tmp_path / f"scan-{index}" + scan_dir.mkdir() + write_completed_contract(scan_dir, str(uuid.uuid4()), target) + VALIDATOR.FINALIZER.finalize_scan(scan_dir) + sources.append((scan_dir, VALIDATOR.validate_contract(scan_dir))) + first_dir, first = sources[0] + second_dir, second = sources[1] + created = receipt_for(first) + reused = receipt_for(second, operation="reuse", readback={"status": "verified"}) + assert created["findingId"] == reused["findingId"] + assert created["occurrenceId"] != reused["occurrenceId"] + issues(state, first_dir, "record", receipts=[created]) + assert ( + issues(state, second_dir, "inspect")["receipts"][0]["occurrenceId"] + == created["occurrenceId"] + ) + issues(state, second_dir, "record", receipts=[reused]) + history = issues(state, second_dir, "inspect")["receipts"] + assert [entry["operation"] for entry in history] == ["create", "reuse"] + assert len({entry["scanId"] for entry in history}) == 2 + + +@pytest.mark.parametrize("mutation", ["finding", "occurrence", "scan", "seal"]) +def test_invalid_source_is_rejected_before_creating_state(tmp_path: Path, mutation: str) -> None: + scan_dir, validated = source(tmp_path) + state = tmp_path / "state" + receipt = receipt_for(validated) + payload = { + "action": "record", + "scanDirectory": str(scan_dir), + "destination": LINEAR, + "receipts": [receipt], + } + if mutation == "finding": + payload["findingIds"] = ["not-a-finding"] + elif mutation == "occurrence": + receipt["occurrenceId"] = "not-an-occurrence" + elif mutation == "scan": + payload["expectedScanId"] = "not-this-scan" + else: + (scan_dir / "findings.json").write_text("{}") + result = run_workbench(state, "finding-issues", input_text=json.dumps(payload), check=False) + assert result["returncode"] != 0 + assert not state.exists() diff --git a/plugins/codex-security/tests/test_workbench_db.py b/plugins/codex-security/tests/test_workbench_db.py index 44c93f60ef..59b69dad2b 100644 --- a/plugins/codex-security/tests/test_workbench_db.py +++ b/plugins/codex-security/tests/test_workbench_db.py @@ -26,6 +26,7 @@ fail_scan, get_scan, initialize_git_repository, + load_script, mark_handoff_delivered, mark_remediation_delivered, request_remediation, @@ -79,7 +80,7 @@ "finding_embeddings", "finding_locations", "finding_occurrences", - "finding_publications", + "finding_issue_receipts", "finding_remediation_attempts", "finding_repositories", "finding_triage", @@ -919,7 +920,9 @@ def test_workbench_persists_progress_and_indexes_completed_findings(tmp_path: Pa ) } assert tables == EXPECTED_TABLES - assert connection.execute("SELECT COUNT(*) FROM schema_migrations").fetchone() == (42,) + assert connection.execute("SELECT COUNT(*) FROM schema_migrations").fetchone() == ( + len(load_script("workbench_schema").MIGRATIONS), + ) assert connection.execute("SELECT COUNT(*) FROM findings").fetchone() == (1,) assert connection.execute("SELECT COUNT(*) FROM finding_locations").fetchone() == (1,) diff --git a/plugins/codex-security/tests/test_workbench_deep_scan.py b/plugins/codex-security/tests/test_workbench_deep_scan.py index 456b2bd6a8..46ece6d34b 100644 --- a/plugins/codex-security/tests/test_workbench_deep_scan.py +++ b/plugins/codex-security/tests/test_workbench_deep_scan.py @@ -25,6 +25,7 @@ finish_deep_scan, get_deep_scan, get_scan, + load_script, mark_deep_coordinator_succeeded, resume_deep_scan, run_workbench, @@ -276,7 +277,9 @@ def claim() -> dict[str, object]: return claim_deep_scan_coordinator(state_dir, codex_home, scan_id) with sqlite3.connect(state_dir / "workbench.sqlite3") as connection: - assert connection.execute("SELECT MAX(version) FROM schema_migrations").fetchone() == (42,) + assert connection.execute("SELECT MAX(version) FROM schema_migrations").fetchone() == ( + len(load_script("workbench_schema").MIGRATIONS), + ) assert claim()["deepScan"]["coordinatorGeneration"] == 2 assert claim()["coordinatorDisposition"] == "observing" expire_deep_scan_coordinator(state_dir, scan_id) diff --git a/plugins/codex-security/tests/test_workbench_setup_and_migrations.py b/plugins/codex-security/tests/test_workbench_setup_and_migrations.py index 8225b0822a..14ee071d98 100644 --- a/plugins/codex-security/tests/test_workbench_setup_and_migrations.py +++ b/plugins/codex-security/tests/test_workbench_setup_and_migrations.py @@ -82,6 +82,7 @@ (40, "index finding identity and comparison history"), (41, "checkpoint finding severity assessments"), (42, "editable scan names"), + (43, "share finding issue receipts across trackers"), ] @@ -617,7 +618,9 @@ def test_workbench_serializes_concurrent_migrations(tmp_path: Path, upgrade: boo {"databasePath": str(state_dir / "workbench.sqlite3")}, ] with sqlite3.connect(state_dir / "workbench.sqlite3") as connection: - assert connection.execute("SELECT COUNT(*) FROM schema_migrations").fetchone() == (42,) + assert connection.execute("SELECT COUNT(*) FROM schema_migrations").fetchone() == ( + len(SCHEMA.MIGRATIONS), + ) def test_workbench_retries_writer_admission_and_legacy_backfill(tmp_path: Path) -> None: @@ -1031,7 +1034,7 @@ def test_workbench_reconciles_monorepo_migration_lineage() -> None: ] assert ( connection.execute( - "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'finding_publications'" + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'finding_issue_receipts'" ).fetchone() is not None ) @@ -1039,13 +1042,10 @@ def test_workbench_reconciles_monorepo_migration_lineage() -> None: row[0] for row in connection.execute( "SELECT name FROM sqlite_master WHERE type = 'index' " - "AND tbl_name = 'finding_publications'" + "AND tbl_name = 'finding_issue_receipts'" ) } >= { - "finding_publications_by_scan", - "finding_publications_by_finding", - "finding_publications_team_only_occurrence", - "finding_publications_team_only_external_issue", + "finding_issue_receipts_by_finding", } assert [ row["name"] @@ -1169,7 +1169,9 @@ def test_workbench_upgrades_preexisting_database(tmp_path: Path) -> None: connection.execute("ALTER TABLE scans DROP COLUMN handoff_claim_token") run_workbench(state_dir, "database-info") with sqlite3.connect(database) as connection: - assert connection.execute("SELECT MAX(version) FROM schema_migrations").fetchone() == (42,) + assert connection.execute("SELECT MAX(version) FROM schema_migrations").fetchone() == ( + len(SCHEMA.MIGRATIONS), + ) assert {row[1] for row in connection.execute("PRAGMA table_info(scans)")} >= { "handoff_claimed_at", "handoff_claim_token", diff --git a/sdk/typescript/docs/cli.md b/sdk/typescript/docs/cli.md index fbab74222e..0317232b3f 100644 --- a/sdk/typescript/docs/cli.md +++ b/sdk/typescript/docs/cli.md @@ -820,10 +820,16 @@ creation; connected-app access is reported as `not-checked`. Each finding becomes an issue with severity, source, evidence, and remediation. Choose a destination authorized to receive that content. Local history records -successful issue IDs separately from sealed artifacts. +successful issue IDs separately from sealed artifacts. The CLI and the plugin's +finding-tracking workflow share these records when using the same Codex Security +state directory. Accepted writes remain recorded even if a later issue read fails. +The shared-history migration preserves existing Linear receipts and replaces their +old table. Upgrade CLI and plugin installations together when they share state; +older publishers cannot write to the migrated database. Republishing creates duplicates unless `--skip-existing` finds a recorded success -for that occurrence, team, and project. It does not check remote issues or cover +for that occurrence, team, and project, including issues created or reused through +the plugin. It does not check remote issues or cover unrecorded/concurrent publication. After interruption, inspect the handoff, evidence, and destination before retrying. diff --git a/sdk/typescript/src/publication-store.ts b/sdk/typescript/src/publication-store.ts index 82895d8678..6a4929b1a4 100644 --- a/sdk/typescript/src/publication-store.ts +++ b/sdk/typescript/src/publication-store.ts @@ -1,59 +1,31 @@ -import { findingEntry } from "./value.js"; -import { mkdtemp, realpath, rm, stat, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { isRecord } from "./record.js"; -import { CodexSecurityError } from "./errors.js"; import type { PreparedScanPublication } from "./publication.js"; import type { PublishedScanIssue } from "./publish.js"; -import { - codexSecurityStateDirectory, - requireOutputOutsideRepository, - resolveWorkbenchRuntime, - runWorkbench, -} from "./runtime.js"; +import { resolveWorkbenchRuntime, runWorkbench } from "./runtime.js"; + +type StoredIssue = PublishedScanIssue & { scanId: string }; export async function inspectPublicationStore( publication: PreparedScanPublication, environment: NodeJS.ProcessEnv, signal?: AbortSignal, ): Promise { - const result = await runPublicationWorkbench( - "inspect-linear-publication", + const receipts = await findingIssues( + "inspect", publication, environment, - undefined, + [], signal, ); - const recorded = result["recorded"]; - if ( - !matchesPublication(result, publication) || - result["findingCount"] !== - (publication.sourceFindings ?? publication.issues).length || - !Array.isArray(recorded) - ) { - throw invalidPublicationRecords(); - } - const expected = new Map( - (publication.sourceFindings ?? 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]; + return publication.issues.flatMap(({ findingId, occurrenceId }) => { + // Tracking can reuse an issue across scans. CLI skipping remains specific to + // the selected occurrence, irrespective of which workflow recorded it. + const receipt = receipts.find( + (issue) => + issue.scanId === publication.scanId && + issue.findingId === findingId && + issue.occurrenceId === occurrenceId, + ); + return receipt === undefined ? [] : [publishedIssue(receipt)]; }); } @@ -61,20 +33,7 @@ export async function preparePublicationStore( publication: PreparedScanPublication, environment: NodeJS.ProcessEnv, ): Promise { - const result = await runPublicationWorkbench( - "prepare-linear-publication", - publication, - environment, - ); - if ( - result["scanId"] !== publication.scanId || - result["findingCount"] !== - (publication.sourceFindings ?? publication.issues).length - ) { - throw new CodexSecurityError( - "The workbench could not verify every finding selected for publication.", - ); - } + await findingIssues("prepare", publication, environment); } export async function recordPublishedIssues( @@ -82,154 +41,73 @@ export async function recordPublishedIssues( issues: readonly PublishedScanIssue[], environment: NodeJS.ProcessEnv, ): Promise { - const result = await runPublicationWorkbench( - "record-linear-publications", + const receipts = await findingIssues( + "record", publication, environment, issues, ); - const created = result["created"]; - if ( - !matchesPublication(result, publication) || - !Array.isArray(created) || - created.length !== issues.length - ) { - throw invalidPublicationRecords(); - } - - const expected = new Map(issues.map(findingEntry)); - const ordered = publication.issues.flatMap((issue) => { - const record = expected.get(issue.findingId); - return record === undefined ? [] : [record]; - }); - if (expected.size !== issues.length || ordered.length !== issues.length) { - throw invalidPublicationRecords(); - } - - return created.map((value, index) => { - const expectedIssue = ordered[index]; - const issue = readPublicationRecord(value); - if ( - expectedIssue === undefined || - issue.findingId !== expectedIssue.findingId || - issue.occurrenceId !== expectedIssue.occurrenceId || - issue.issueIdentifier !== expectedIssue.issueIdentifier || - (expectedIssue.url !== undefined && issue.url !== expectedIssue.url) - ) { - throw invalidPublicationRecords(); - } - return issue; + const recorded = new Map( + receipts.map((receipt) => [receipt.findingId, receipt]), + ); + return publication.issues.flatMap(({ findingId }) => { + const receipt = recorded.get(findingId); + return receipt === undefined ? [] : [publishedIssue(receipt)]; }); } -async function runPublicationWorkbench( - command: - | "inspect-linear-publication" - | "prepare-linear-publication" - | "record-linear-publications", +async function findingIssues( + action: "inspect" | "prepare" | "record", publication: PreparedScanPublication, environment: NodeJS.ProcessEnv, - issues?: readonly PublishedScanIssue[], + issues: readonly PublishedScanIssue[] = [], signal?: AbortSignal, -): Promise> { +): Promise { signal?.throwIfAborted(); - const stateDirectory = codexSecurityStateDirectory(environment); - const database = join(stateDirectory, "workbench.sqlite3"); - try { - if (!(await stat(database)).isFile()) throw new Error("not a regular file"); - } catch (error) { - throw new CodexSecurityError( - "Cannot publish findings because the local Codex Security scan-history database does not exist. Use the state directory where this scan was completed.", - { cause: error }, - ); - } const [python, pluginRoot] = await resolveWorkbenchRuntime({ environment, protectedRoot: publication.scanDirectory, - ...(signal === undefined ? {} : { signal }), + signal, }); - signal?.throwIfAborted(); - const findings = (publication.sourceFindings ?? publication.issues).map( - ({ findingId, occurrenceId }) => ({ - findingId, - occurrenceId, + const result = await runWorkbench( + { + python, + pluginRoot, + environment, + signal, + failureMessage: + action === "record" + ? "Could not persist created Linear issues in the local Codex Security scan history" + : "Cannot publish findings without their existing local Codex Security scan history", + }, + ["finding-issues"], + JSON.stringify({ + action, + scanDirectory: publication.scanDirectory, + expectedScanId: publication.scanId, + destination: publication.destination, + findingIds: (publication.sourceFindings ?? publication.issues).map( + ({ findingId }) => findingId, + ), + requireHistory: true, + ...(action === "record" + ? { + receipts: issues.map((issue) => ({ + ...issue, + operation: "create", + })), + } + : {}), }), ); - let temporaryRoot = stateDirectory; - if (command === "inspect-linear-publication") { - temporaryRoot = await realpath(tmpdir()); - const scanRoot = await realpath(publication.scanDirectory); - requireOutputOutsideRepository(scanRoot, temporaryRoot, "temporary"); - } - const directory = await mkdtemp(join(temporaryRoot, "publication-")); - try { - const input = join(directory, "publication.json"); - await writeFile( - input, - JSON.stringify({ - scanId: publication.scanId, - scanDirectory: publication.scanDirectory, - destination: publication.destination, - findings, - ...(issues === undefined ? {} : { publications: issues }), - }), - { encoding: "utf8", flag: "wx", mode: 0o600 }, - ); - return await runWorkbench( - { - python, - pluginRoot, - environment, - ...(signal === undefined ? {} : { signal }), - failureMessage: - 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], - ); - } finally { - await rm(directory, { recursive: true, force: true }).catch( - () => undefined, - ); - } + return result["receipts"] as unknown as StoredIssue[]; } -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(); - } +function publishedIssue(issue: StoredIssue): PublishedScanIssue { return { - findingId: value["findingId"], - occurrenceId: value["occurrenceId"], - issueIdentifier: value["issueIdentifier"], - ...(typeof value["url"] === "string" ? { url: value["url"] } : {}), + findingId: issue.findingId, + occurrenceId: issue.occurrenceId, + issueIdentifier: issue.issueIdentifier, + ...(issue.url === undefined ? {} : { url: issue.url }), }; } - -function invalidPublicationRecords(): CodexSecurityError { - return new CodexSecurityError( - "The workbench returned invalid persisted Linear publication records.", - ); -} diff --git a/sdk/typescript/tests-ts/publication-integration.test.ts b/sdk/typescript/tests-ts/publication-integration.test.ts index 9686700ac1..68389a9298 100644 --- a/sdk/typescript/tests-ts/publication-integration.test.ts +++ b/sdk/typescript/tests-ts/publication-integration.test.ts @@ -276,7 +276,7 @@ function storedPublications(fixture: PublicationFixture): StoredPublication[] { "import json, sqlite3, sys", "connection = sqlite3.connect(sys.argv[1])", "connection.row_factory = sqlite3.Row", - "rows = connection.execute('SELECT scan_id, finding_id, occurrence_id, destination_type, team_id, project_id, external_id, external_url FROM finding_publications ORDER BY id').fetchall()", + `rows = connection.execute("SELECT scan_id, finding_id, occurrence_id, json_extract(destination_key, '$[0]') AS destination_type, json_extract(destination_key, '$[1]') AS team_id, json_extract(destination_key, '$[2]') AS project_id, external_id, external_url FROM finding_issue_receipts ORDER BY id").fetchall()`, "print(json.dumps([dict(row) for row in rows]))", ].join("\n"); return JSON.parse( @@ -298,6 +298,119 @@ function receiptPath(fixture: PublicationFixture): string { } describe("database-backed Linear publication integration", () => { + test("skips a Linear issue recorded through the shared tracking workbench", async () => { + const completed = await fixture(1); + const finding = completed.findings[0]!; + const sealed = await artifactDigests(completed.scanDirectory); + const issue = { + findingId: finding.findingId, + occurrenceId: finding.occurrenceId, + issueIdentifier: "EXAMPLE-711", + url: "https://linear.app/example/issue/EXAMPLE-711", + }; + await runWorkbench( + { + python: completed.python, + pluginRoot: PLUGIN_ROOT, + environment: completed.environment, + }, + ["finding-issues"], + JSON.stringify({ + action: "record", + scanDirectory: completed.scanDirectory, + destination: { + type: "linear", + teamId: OPTIONS.teamId, + projectId: OPTIONS.projectId, + }, + receipts: [ + { + ...issue, + operation: "create", + readback: { + status: "failed", + error: "Synthetic readback unavailable", + }, + }, + ], + }), + ); + const result = await publishScanInternal( + completed.scanDirectory, + { + ...OPTIONS, + skipExisting: true, + }, + { + environment: completed.environment, + resolveCodex: throwing( + "A recorded tracker issue must not be created again.", + ), + }, + ); + expect(result.skipped).toEqual([issue]); + expect(result.created).toEqual([]); + expect(result.counts).toEqual({ + findings: 1, + created: 0, + failed: 0, + skipped: 1, + }); + expect(await artifactDigests(completed.scanDirectory)).toEqual(sealed); + }); + + test("exposes a CLI-created issue to the shared tracking workbench", async () => { + const completed = await fixture(1); + type LinearClient = ReturnType< + NonNullable + >; + const result = await publishScanInternal( + completed.scanDirectory, + { + ...OPTIONS, + linearApiKey: "lin_api_SYNTHETIC_INTEROPERABILITY", + }, + { + environment: completed.environment, + linearClient: () => + ({ + createIssue: async () => ({ + success: true, + issue: Promise.resolve({ + identifier: "EXAMPLE-712", + url: "https://linear.app/example/issue/EXAMPLE-712", + }), + }), + }) as unknown as LinearClient, + }, + ); + expect(result.created).toHaveLength(1); + const inspected = await runWorkbench( + { + python: completed.python, + pluginRoot: PLUGIN_ROOT, + environment: completed.environment, + }, + ["finding-issues"], + JSON.stringify({ + action: "inspect", + scanDirectory: completed.scanDirectory, + destination: { + type: "linear", + teamId: OPTIONS.teamId, + projectId: OPTIONS.projectId, + }, + }), + ); + expect(inspected["receipts"]).toEqual([ + expect.objectContaining({ + ...result.created[0], + scanId: SCAN_ID, + operation: "create", + }), + ]); + }); + test("publishes classified selections while verifying the complete scan history and preserving earlier tickets", async () => { const { classifyScanDirectorySeverity } = await import("../src/classify-scan-severity.js"); diff --git a/sdk/typescript/tests-ts/publication-store.test.ts b/sdk/typescript/tests-ts/publication-store.test.ts index 4e4e74b843..33513166eb 100644 --- a/sdk/typescript/tests-ts/publication-store.test.ts +++ b/sdk/typescript/tests-ts/publication-store.test.ts @@ -2,15 +2,15 @@ import { once } from "node:events"; import { createHash, randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; import { + chmod, + cp, mkdir, readFile, readdir, stat, - symlink, writeFile, } from "node:fs/promises"; -import * as os from "node:os"; -import { dirname, join, toNamespacedPath } from "node:path"; +import { join, toNamespacedPath } from "node:path"; import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { inspectPublicationStore, @@ -21,7 +21,12 @@ import { prepareScanPublication, type PreparedScanPublication, } from "../src/publication.js"; -import type { FindingsDocument, ScanManifest } from "../src/models.js"; +import type { + CoverageDocument, + FindingsDocument, + ScanManifest, +} from "../src/models.js"; +import { setFindingIdentity, sha256 } from "./support/finding-identity.js"; import type { PublishedScanIssue } from "../src/publish.js"; import { runWorkbench } from "../src/runtime.js"; import * as runtime from "../src/runtime.js"; @@ -67,22 +72,11 @@ async function publicationFixture( CODEX_SECURITY_STATE_DIR: stateDirectory, PYTHON: python, }; - const publication: PreparedScanPublication = { - scanId: SCAN_ID, + const publication = await sealedPublication( scanDirectory, - destination: { - type: "linear", - teamId: "team-example", - projectId: "project-example", - }, - issues: Array.from({ length: options.count ?? 2 }, (_, index) => ({ - findingId: `finding-${index + 1}`, - occurrenceId: `occurrence-${index + 1}`, - title: `[Codex Security][HIGH] Example finding ${index + 1}`, - description: `Example finding ${index + 1}`, - priority: 2, - })), - }; + environment, + options.count ?? 2, + ); const fixture = { environment, publication, python, stateDirectory }; if (options.createDatabase !== false) { await mkdir(stateDirectory, { mode: 0o700 }); @@ -94,6 +88,52 @@ async function publicationFixture( return fixture; } +async function sealedPublication( + scanDirectory: string, + environment: NodeJS.ProcessEnv, + count: number, + scanId = SCAN_ID, +): Promise { + await copyCompletedScanFixture(scanDirectory); + const manifestPath = join(scanDirectory, "scan-manifest.json"); + const findingsPath = join(scanDirectory, "findings.json"); + const coveragePath = join(scanDirectory, "coverage.json"); + const manifest = JSON.parse( + await readFile(manifestPath, "utf8"), + ) as ScanManifest; + const findings = JSON.parse( + await readFile(findingsPath, "utf8"), + ) as FindingsDocument; + const coverage = JSON.parse( + await readFile(coveragePath, "utf8"), + ) as CoverageDocument; + manifest.scan.id = scanId; + findings.scanId = scanId; + coverage.scanId = scanId; + const example = findings.findings[0]!; + findings.findings = Array.from({ length: count }, (_, index) => { + const finding = structuredClone(example); + finding.identity.anchor = `${example.identity.anchor}-${index + 1}`; + finding.title = `Synthetic finding ${index + 1}`; + setFindingIdentity(manifest.scan, finding); + return finding; + }); + await writeFile(findingsPath, `${JSON.stringify(findings, null, 2)}\n`); + await writeFile(coveragePath, `${JSON.stringify(coverage, null, 2)}\n`); + for (const artifact of manifest.scan.artifacts) { + artifact.sha256 = sha256( + await readFile(join(scanDirectory, artifact.path)), + ); + } + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + return prepareScanPublication(scanDirectory, { + destination: "linear", + teamId: "team-example", + projectId: "project-example", + environment, + }); +} + function seedPublicationScan( fixture: PublicationFixture, publication: PreparedScanPublication, @@ -178,37 +218,6 @@ describe("read-only publication history", () => { }), ).resolves.toEqual([]); } - - const check = runNodePython(fixture.python, [ - "-c", - [ - "import sys", - "from copy import copy", - "from pathlib import PureWindowsPath", - "from urllib.parse import unquote, urlsplit", - "sys.path.insert(0, sys.argv[1])", - "import workbench_db as workbench", - "import workbench_publication as publication", - "class Captured(Exception): pass", - "def capture(filename, **options):", - " parsed = urlsplit(filename)", - " assert parsed.scheme == 'file' and parsed.netloc == ''", - " assert unquote(parsed.path) == str(path)", - " assert parsed.query == 'mode=ro' and options == {'uri': True, 'timeout': 5}", - " raise Captured", - "publication.sqlite3.connect = capture", - "publication.linear_publication_input = lambda *_args, **_options: ({}, {}, [])", - "for value in ['C:/state/history.sqlite3', '//server/share/state/history.sqlite3', '//?/C:/state/history.sqlite3', '//?/UNC/server/share/history.sqlite3']:", - " path = PureWindowsPath(value)", - " context = copy(workbench._WORKBENCH_PUBLICATION_CONTEXT)", - " context.database_path = lambda path=path: path", - " try: publication.inspect_linear_publication(context, None)", - " except Captured: pass", - " else: raise AssertionError('SQLite connection was not attempted')", - ].join("\n"), - join(PLUGIN_ROOT, "scripts"), - ]); - expect(check.status, check.stderr).toBe(0); }); test("matches the manifest recorded when the scan completed", async () => { @@ -269,37 +278,10 @@ describe("read-only publication history", () => { ).toEqual([{ seal_manifest_digest: digest }]); }); - test("keeps inspection temporaries outside the completed scan", async () => { - const fixture = await publicationFixture(); - const scan = fixture.publication.scanDirectory; - const nested = join(scan, "temporary"); - const alias = join(dirname(fixture.stateDirectory), "temporary-link"); - await mkdir(nested); - await symlink( - nested, - alias, - process.platform === "win32" ? "junction" : "dir", - ); - const temporary = spyOn(os, "tmpdir"); - try { - for (const root of [scan, nested, alias]) { - temporary.mockReturnValue(root); - await expect( - inspectPublicationStore(fixture.publication, fixture.environment), - ).rejects.toThrow(/temporary directory must be outside/u); - expect(await readdir(scan)).toEqual(["temporary"]); - expect(await readdir(nested)).toEqual([]); - } - } finally { - temporary.mockRestore(); - } - }); - - test("forwards cancellation to Python discovery and the workbench and cleans up its input", async () => { + test("forwards inspection cancellation through Python discovery and the shared workbench", async () => { const fixture = await publicationFixture(); const controller = new AbortController(); const reason = new Error("Synthetic inspection cancellation."); - let inputFile = ""; const inspecting = Promise.withResolvers(); const python = spyOn(runtime, "resolvePluginPython").mockImplementation( async (options) => { @@ -308,11 +290,16 @@ describe("read-only publication history", () => { }, ); const workbench = spyOn(runtime, "runWorkbench").mockImplementation( - async (options, args) => { + async (options, args, input) => { inspecting.resolve(); expect(options.signal).toBe(controller.signal); - expect(args[0]).toBe("inspect-linear-publication"); - inputFile = args[args.indexOf("--input-file") + 1]!; + expect(args).toEqual(["finding-issues"]); + expect(JSON.parse(input!)).toMatchObject({ + action: "inspect", + scanDirectory: fixture.publication.scanDirectory, + destination: fixture.publication.destination, + requireHistory: true, + }); await once(options.signal!, "abort"); throw options.signal!.reason; }, @@ -326,8 +313,6 @@ describe("read-only publication history", () => { await inspecting.promise; 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(); @@ -358,11 +343,11 @@ describe("read-only publication history", () => { expect(existsSync(missing.stateDirectory)).toBe(false); const fixture = await publicationFixture(); - databaseRows(fixture, "DROP TABLE finding_publications"); + databaseRows(fixture, "DROP TABLE finding_issue_receipts"); databaseRows( fixture, "DELETE FROM schema_migrations WHERE version >= ?", - [29], + [43], ); const database = join(fixture.stateDirectory, "workbench.sqlite3"); const before = await readFile(database); @@ -381,13 +366,13 @@ describe("read-only publication history", () => { databaseRows( fixture, "SELECT version FROM schema_migrations WHERE version >= ?", - [29], + [43], ), ).toEqual([]); expect( databaseRows( fixture, - "SELECT name FROM sqlite_master WHERE name = 'finding_publications'", + "SELECT name FROM sqlite_master WHERE name = 'finding_issue_receipts'", ), ).toEqual([]); }); @@ -470,25 +455,57 @@ describe("read-only publication history", () => { const scanDirectory = join(fixture.stateDirectory, "another-scan"); await mkdir(scanDirectory, { mode: 0o700 }); - const otherScan: PreparedScanPublication = { - ...fixture.publication, - scanId: OTHER_SCAN_ID, + const otherScan = await sealedPublication( scanDirectory, - issues: fixture.publication.issues.map((issue) => ({ - ...issue, - occurrenceId: `other-${issue.occurrenceId}`, - })), - }; + fixture.environment, + 2, + OTHER_SCAN_ID, + ); seedPublicationScan(fixture, otherScan); await expect( inspectPublicationStore(otherScan, fixture.environment), ).resolves.toEqual([]); + const candidates = await runWorkbench( + { + python: fixture.python, + pluginRoot: PLUGIN_ROOT, + environment: fixture.environment, + }, + ["finding-issues"], + JSON.stringify({ + action: "inspect", + scanDirectory, + destination: otherScan.destination, + findingIds: [otherScan.issues[0]!.findingId], + }), + ); + expect(candidates["receipts"]).toEqual([ + expect.objectContaining({ + ...first, + scanId: SCAN_ID, + operation: "create", + }), + expect.objectContaining({ + issueIdentifier: "EXAMPLE-201", + scanId: SCAN_ID, + }), + ]); + await expect( + inspectPublicationStore( + { + ...fixture.publication, + issues: [fixture.publication.issues[0]!], + sourceFindings: fixture.publication.issues, + }, + fixture.environment, + ), + ).resolves.toEqual([first]); await expect( inspectPublicationStore( { ...fixture.publication, issues: [fixture.publication.issues[0]!] }, fixture.environment, ), - ).rejects.toThrow(/exactly match/u); + ).rejects.toThrow(/finding|match/u); }); test("includes committed associations that are still in the WAL", async () => { @@ -507,7 +524,7 @@ describe("read-only publication history", () => { "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.execute('UPDATE finding_issue_receipts SET external_id = ?, external_url = ?', (sys.argv[2], sys.argv[3]))", "connection.commit()", "os._exit(0)", ].join("\n"), @@ -524,6 +541,73 @@ describe("read-only publication history", () => { }); describe("persisted finding publication associations", () => { + test("reads legacy Linear receipts without migration and preserves them when preparing shared history", async () => { + const fixture = await publicationFixture({ + count: 1, + createDatabase: false, + }); + await mkdir(fixture.stateDirectory, { mode: 0o700 }); + const database = join(fixture.stateDirectory, "workbench.sqlite3"); + const setup = runNodePython(fixture.python, [ + "-c", + [ + "import sqlite3, sys", + "sys.path.insert(0, sys.argv[1])", + "from workbench_schema import MIGRATIONS, apply_migrations", + "connection = sqlite3.connect(sys.argv[2])", + "connection.row_factory = sqlite3.Row", + "apply_migrations(connection, tuple(item for item in MIGRATIONS if item[0] <= 42), lambda: '2026-08-01T00:00:00Z', lambda _: None)", + "connection.close()", + ].join("\n"), + join(PLUGIN_ROOT, "scripts"), + database, + ]); + expect(setup.status, setup.stderr).toBe(0); + seedPublicationScan(fixture, fixture.publication); + const original = publishedIssue(fixture.publication, 0, "EXAMPLE-450"); + databaseRows( + fixture, + "INSERT INTO finding_publications (scan_id, finding_id, occurrence_id, destination_type, team_id, project_id, external_id, external_url, created_at) VALUES (?, ?, ?, 'linear', ?, ?, ?, ?, '2026-08-01T00:00:00Z')", + [ + SCAN_ID, + original.findingId, + original.occurrenceId, + fixture.publication.destination.teamId, + fixture.publication.destination.projectId, + original.issueIdentifier, + original.url, + ], + ); + const before = await readFile(database); + await expect( + inspectPublicationStore(fixture.publication, fixture.environment), + ).resolves.toEqual([original]); + expect(await readFile(database)).toEqual(before); + + await preparePublicationStore(fixture.publication, fixture.environment); + await expect( + inspectPublicationStore(fixture.publication, fixture.environment), + ).resolves.toEqual([original]); + expect( + databaseRows( + fixture, + "SELECT operation, external_id, external_url FROM finding_issue_receipts", + ), + ).toEqual([ + { + operation: "create", + external_id: original.issueIdentifier, + external_url: original.url, + }, + ]); + expect( + databaseRows( + fixture, + "SELECT name FROM sqlite_master WHERE name = 'finding_publications'", + ), + ).toEqual([]); + }); + test("rolls back failed migrations without losing populated scan history", async () => { const fixture = await publicationFixture({ count: 1 }); await recordPublishedIssues( @@ -571,7 +655,7 @@ db.apply_migrations(connection) assert list(connection.iterdump()) == before, "Reapplying migrations changed existing history" assert list(connection.execute("PRAGMA foreign_key_check")) == [] assert connection.execute("PRAGMA integrity_check").fetchone()[0] == "ok" -print(json.dumps({table: connection.execute("SELECT COUNT(*) FROM " + table).fetchone()[0] for table in ("scans", "finding_occurrences", "finding_triage", "finding_publications")})) +print(json.dumps({table: connection.execute("SELECT COUNT(*) FROM " + table).fetchone()[0] for table in ("scans", "finding_occurrences", "finding_triage", "finding_issue_receipts")})) connection.close() `, join(PLUGIN_ROOT, "scripts"), @@ -584,44 +668,10 @@ connection.close() scans: 1, finding_occurrences: 1, finding_triage: 1, - finding_publications: 1, + finding_issue_receipts: 1, }); }); - test("upgrades existing scan history and verifies every completed finding before publication", async () => { - const fixture = await publicationFixture(); - databaseRows(fixture, "DROP TABLE finding_publications"); - databaseRows( - fixture, - "DELETE FROM schema_migrations WHERE version IN (?, ?)", - [29, 30], - ); - - await expect( - preparePublicationStore(fixture.publication, fixture.environment), - ).resolves.toBeUndefined(); - - expect( - databaseRows( - fixture, - "SELECT version, name FROM schema_migrations WHERE version BETWEEN ? AND ? ORDER BY version", - [29, 30], - ), - ).toEqual([ - { version: 29, name: "persist finding publication associations" }, - { - version: 30, - name: "preserve team-only finding publication associations", - }, - ]); - expect( - databaseRows( - fixture, - "SELECT COUNT(*) AS count FROM finding_publications", - ), - ).toEqual([{ count: 0 }]); - }); - test("upgrades scan history from before stopped-result preservation", async () => { const fixture = await publicationFixture(); databaseRows( @@ -666,58 +716,6 @@ connection.close() ).toEqual([{ name: "publication_error_message" }]); }); - test("upgrades existing project-scoped associations without changing recorded issues", async () => { - const fixture = await publicationFixture({ count: 1 }); - const original = publishedIssue(fixture.publication, 0, "EXAMPLE-401"); - await recordPublishedIssues( - fixture.publication, - [original], - fixture.environment, - ); - - databaseRows( - fixture, - "DROP INDEX finding_publications_team_only_occurrence", - ); - databaseRows( - fixture, - "DROP INDEX finding_publications_team_only_external_issue", - ); - databaseRows( - fixture, - "DELETE FROM schema_migrations WHERE version = ?", - [30], - ); - - await expect( - preparePublicationStore(fixture.publication, fixture.environment), - ).resolves.toBeUndefined(); - - expect( - databaseRows( - fixture, - "SELECT version, name FROM schema_migrations WHERE version = ?", - [30], - ), - ).toEqual([ - { - version: 30, - name: "preserve team-only finding publication associations", - }, - ]); - expect( - databaseRows( - fixture, - "SELECT project_id, external_id FROM finding_publications", - ), - ).toEqual([ - { - project_id: "project-example", - external_id: original.issueIdentifier, - }, - ]); - }); - test("rejects a missing local scan-history database without creating one", async () => { const fixture = await publicationFixture({ createDatabase: false }); @@ -737,7 +735,7 @@ connection.close() expect( databaseRows( fixture, - "SELECT COUNT(*) AS count FROM finding_publications", + "SELECT COUNT(*) AS count FROM finding_issue_receipts", ), ).toEqual([{ count: 0 }]); }); @@ -757,7 +755,10 @@ connection.close() test("rejects a selected directory that differs from its recorded scan", async () => { const fixture = await publicationFixture(); const anotherDirectory = join(fixture.stateDirectory, "another-scan"); - await mkdir(anotherDirectory, { mode: 0o700 }); + await cp(fixture.publication.scanDirectory, anotherDirectory, { + recursive: true, + }); + if (process.platform !== "win32") await chmod(anotherDirectory, 0o700); await expect( preparePublicationStore( @@ -767,56 +768,44 @@ connection.close() ).rejects.toThrow(/directory does not match/u); }); - test("rejects missing, mismatched, or omitted scan findings before publication", async () => { - const fixture = await publicationFixture(); - - for (const issues of [ - [ - { - ...fixture.publication.issues[0]!, - occurrenceId: "occurrence-not-in-scan", - }, - fixture.publication.issues[1]!, - ], - [ - { ...fixture.publication.issues[0]!, findingId: "finding-not-in-scan" }, - fixture.publication.issues[1]!, - ], - [fixture.publication.issues[0]!], - ]) { + test.each(["missing", "mismatched"])( + "rejects %s history occurrences for a sealed scan", + async (kind) => { + const fixture = await publicationFixture(); + const occurrenceId = fixture.publication.issues[0]!.occurrenceId; + if (kind === "missing") { + databaseRows(fixture, "DELETE FROM finding_occurrences WHERE id = ?", [ + occurrenceId, + ]); + } else { + databaseRows( + fixture, + "UPDATE finding_occurrences SET id = ? WHERE id = ?", + ["occ_000000000000000000000000", occurrenceId], + ); + } await expect( - preparePublicationStore( - { ...fixture.publication, issues }, - fixture.environment, - ), + preparePublicationStore(fixture.publication, fixture.environment), ).rejects.toThrow(/finding|occurrence/u); - } - }); - - test("rejects a real finding occurrence that belongs to another scan", async () => { - const fixture = await publicationFixture({ count: 1 }); - const anotherDirectory = join(fixture.stateDirectory, "another-scan"); - await mkdir(anotherDirectory, { mode: 0o700 }); - const otherScan: PreparedScanPublication = { - ...fixture.publication, - scanId: OTHER_SCAN_ID, - scanDirectory: anotherDirectory, - issues: [ - { - ...fixture.publication.issues[0]!, - findingId: "finding-other-scan", - occurrenceId: "occurrence-other-scan", - }, - ], - }; - seedPublicationScan(fixture, otherScan); + }, + ); + test("rejects a selection absent from the sealed scan", async () => { + const fixture = await publicationFixture(); await expect( preparePublicationStore( - { ...fixture.publication, issues: otherScan.issues }, + { + ...fixture.publication, + issues: [ + { + ...fixture.publication.issues[0]!, + findingId: "csf_000000000000000000000000", + }, + ], + }, fixture.environment, ), - ).rejects.toThrow(/does not belong to the completed scan/u); + ).rejects.toThrow(/finding/u); }); test("returns only database-backed current results in original finding order", async () => { @@ -834,7 +823,8 @@ connection.close() expect( databaseRows( fixture, - "SELECT scan_id, finding_id, occurrence_id, destination_type, team_id, project_id, external_id, external_url FROM finding_publications ORDER BY finding_id", + "SELECT scan_id, finding_id, occurrence_id, json_extract(destination_key, '$[0]') AS destination_type, json_extract(destination_key, '$[1]') AS team_id, json_extract(destination_key, '$[2]') AS project_id, external_id, external_url FROM finding_issue_receipts ORDER BY CASE WHEN finding_id = ? THEN 0 ELSE 1 END", + [first.findingId], ), ).toEqual([ { @@ -869,11 +859,11 @@ connection.close() recordPublishedIssues(fixture.publication, [issue], fixture.environment), ).resolves.toEqual([issue]); expect( - databaseRows(fixture, "SELECT external_url FROM finding_publications"), + databaseRows(fixture, "SELECT external_url FROM finding_issue_receipts"), ).toEqual([{ external_url: null }]); }); - test("persists team-only issues with a null project and rejects conflicting associations", async () => { + test("persists and replays team-only issues without inventing a project", async () => { const fixture = await publicationFixture(); const publication: PreparedScanPublication = { ...fixture.publication, @@ -898,32 +888,17 @@ connection.close() expect( databaseRows( fixture, - "SELECT project_id, external_id FROM finding_publications", + "SELECT json_extract(destination_key, '$[2]') AS project_id, external_id FROM finding_issue_receipts", ), ).toEqual([{ project_id: null, external_id: first.issueIdentifier }]); - await expect( - recordPublishedIssues( - publication, - [{ ...second, issueIdentifier: first.issueIdentifier }], - fixture.environment, - ), - ).rejects.toThrow(/already associated with a different finding/u); - await expect( - recordPublishedIssues( - publication, - [{ ...first, url: "https://linear.app/example/issue/EXAMPLE-OTHER" }], - fixture.environment, - ), - ).rejects.toThrow(/already associated with a different URL/u); - await expect( recordPublishedIssues(publication, [first, second], fixture.environment), ).resolves.toEqual([first, second]); expect( databaseRows( fixture, - "SELECT project_id, external_id FROM finding_publications ORDER BY id", + "SELECT json_extract(destination_key, '$[2]') AS project_id, external_id FROM finding_issue_receipts ORDER BY id", ), ).toEqual([ { project_id: null, external_id: first.issueIdentifier }, @@ -962,13 +937,13 @@ connection.close() expect( databaseRows( fixture, - "SELECT COUNT(*) AS count FROM finding_publications", + "SELECT COUNT(*) AS count FROM finding_issue_receipts", ), ).toEqual([{ count: 3 }]); expect( databaseRows( fixture, - "SELECT external_id FROM finding_publications WHERE finding_id = ? ORDER BY id", + "SELECT external_id FROM finding_issue_receipts WHERE finding_id = ? ORDER BY id", [original.findingId], ), ).toEqual([ @@ -977,15 +952,13 @@ connection.close() ]); }); - test("rejects swapped occurrences, duplicate mappings, and malformed issue IDs", async () => { + test("rejects mismatched occurrence identities and missing issue identifiers", async () => { const fixture = await publicationFixture(); const first = publishedIssue(fixture.publication, 0); const second = publishedIssue(fixture.publication, 1); for (const records of [ [{ ...first, occurrenceId: second.occurrenceId }], - [first, { ...first, issueIdentifier: "EXAMPLE-999" }], - [first, { ...second, issueIdentifier: first.issueIdentifier }], [{ ...first, issueIdentifier: " " }], ]) { await expect( @@ -999,41 +972,31 @@ connection.close() expect( databaseRows( fixture, - "SELECT COUNT(*) AS count FROM finding_publications", + "SELECT COUNT(*) AS count FROM finding_issue_receipts", ), ).toEqual([{ count: 0 }]); }); - test("rolls back the entire import when an issue belongs to another finding", async () => { + test("rolls back a receipt batch containing an occurrence outside the sealed scan", async () => { const fixture = await publicationFixture(); - const existing = publishedIssue(fixture.publication, 0, "EXAMPLE-301"); - await recordPublishedIssues( - fixture.publication, - [existing], - fixture.environment, - ); - await expect( recordPublishedIssues( fixture.publication, [ publishedIssue(fixture.publication, 0, "EXAMPLE-302"), - publishedIssue(fixture.publication, 1, existing.issueIdentifier), + { + ...publishedIssue(fixture.publication, 1, "EXAMPLE-303"), + occurrenceId: "occ_000000000000000000000000", + }, ], fixture.environment, ), - ).rejects.toThrow(/already associated with a different finding/u); - + ).rejects.toThrow(/finding|occurrence/u); expect( databaseRows( fixture, - "SELECT finding_id, external_id FROM finding_publications ORDER BY id", + "SELECT COUNT(*) AS count FROM finding_issue_receipts", ), - ).toEqual([ - { - finding_id: existing.findingId, - external_id: existing.issueIdentifier, - }, - ]); + ).toEqual([{ count: 0 }]); }); }); diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index 57e6fd798a..3856935210 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -2349,7 +2349,7 @@ ${directNode ? "}" : ""} ), "utf8", ), - ).toContain('"inspect-linear-publication"'); + ).toContain('"finding-issues"'); }); test("rejects plugin installs without the selected path and version", async () => { From b001ea2571011316afbdc9f02a1e82a5fd6b5363 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 8 Oct 2026 02:25:36 +0000 Subject: [PATCH 02/11] fix(tracking): recover issue receipts after plugin restart --- plugins/codex-security/mcp-app/server.ts | 24 +++++++--- .../tests/test_workbench_state_fallback.ts | 45 ++++++++++++++++++- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/plugins/codex-security/mcp-app/server.ts b/plugins/codex-security/mcp-app/server.ts index d6365245ed..d036200759 100644 --- a/plugins/codex-security/mcp-app/server.ts +++ b/plugins/codex-security/mcp-app/server.ts @@ -2463,13 +2463,27 @@ async function executeWorkbenchWithStateSelection( undefined, input, ); - // A lookup without a database must not pin the store; existing history must. + // Recover a known fallback after restart before reporting missing history. if ( - args[0] !== "finding-issues" || - input === undefined || - JSON.parse(input.toString()).action !== "inspect" || - result.storeExists !== false + args[0] === "finding-issues" && + input !== undefined && + JSON.parse(input.toString()).action === "inspect" && + result.storeExists === false ) { + if (CONFIGURED_SCAN_ROOT) { + const stateDir = join(await scanRoot(), "workbench-state"); + const fallback = await executeWorkbench( + pythonCommand, + args, + stateDir, + input, + ); + if (fallback.storeExists === true) { + fallbackWorkbenchStateDir = Promise.resolve(stateDir); + return fallback; + } + } + } else { persistentWorkbenchStateSucceeded = true; } return result; diff --git a/plugins/codex-security/mcp-app/tests/test_workbench_state_fallback.ts b/plugins/codex-security/mcp-app/tests/test_workbench_state_fallback.ts index 3e333a8f3a..1ae485139c 100644 --- a/plugins/codex-security/mcp-app/tests/test_workbench_state_fallback.ts +++ b/plugins/codex-security/mcp-app/tests/test_workbench_state_fallback.ts @@ -457,6 +457,11 @@ async function testWorkbenchStateFallback() { inspected.result.structuredContent.receipts.length, receipts.length, ); + assert.equal( + await pathExists(path.join(issueScanRoot, "workbench-state")), + false, + "Inspecting absent fallback history must not create its directory.", + ); const recorded = await issueServer.request(3, "tools/call", { name: "record_codex_security_finding_issues", arguments: { ...issueInput, receipts: [issueReceipt] }, @@ -475,7 +480,7 @@ async function testWorkbenchStateFallback() { ); assert.deepEqual( (await readJsonLines(invocationLog)).map((entry) => entry.stateDir), - [null, null, fallbackStateDir, fallbackStateDir], + [null, fallbackStateDir, null, fallbackStateDir, fallbackStateDir], ); } else { assertToolError(recorded, /unable to open database file/); @@ -494,6 +499,44 @@ async function testWorkbenchStateFallback() { } finally { await issueServer.stop(); } + if (existingStore === "missing") { + const restarted = startServer(serverBundlePath, { + CODEX_HOME: issueHome, + CODEX_SECURITY_SCAN_ROOT: issueScanRoot, + CODEX_SECURITY_STATE_DIR: undefined, + PYTHON: realPython, + }); + try { + await initialize(restarted, 1); + const inspected = await restarted.request(2, "tools/call", { + name: "get_codex_security_finding_issues", + arguments: issueInput, + }); + assertNoError(inspected); + assert.deepEqual( + inspected.result.structuredContent.receipts.map( + (receipt: { issueIdentifier: string }) => receipt.issueIdentifier, + ), + ["APP-1"], + "Inspection after restart must recover the existing fallback receipts.", + ); + const reused = await restarted.request(3, "tools/call", { + name: "record_codex_security_finding_issues", + arguments: { + ...issueInput, + receipts: [{ ...issueReceipt, operation: "reuse" }], + }, + }); + assertNoError(reused); + assert.equal( + await pathExists(path.join(issueState, "workbench.sqlite3")), + false, + "Recording after restart must keep using the recovered fallback store.", + ); + } finally { + await restarted.stop(); + } + } } await writeFile(invocationLog, ""); From 34f8e1672ce0dea60a670dd128c4ffb4a772d5b8 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 8 Oct 2026 02:42:30 +0000 Subject: [PATCH 03/11] fix(tracking): recover stored history before initial writes --- plugins/codex-security/mcp-app/server.ts | 47 ++--- .../tests/test_workbench_state_fallback.ts | 165 ++++++++++++------ 2 files changed, 135 insertions(+), 77 deletions(-) diff --git a/plugins/codex-security/mcp-app/server.ts b/plugins/codex-security/mcp-app/server.ts index d036200759..b2085a63d8 100644 --- a/plugins/codex-security/mcp-app/server.ts +++ b/plugins/codex-security/mcp-app/server.ts @@ -8,7 +8,7 @@ import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; import { promises as fs } from "node:fs"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { promisify } from "node:util"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import * as z from "zod/v4"; @@ -2456,6 +2456,18 @@ async function executeWorkbenchWithStateSelection( if (persistentWorkbenchStateSucceeded) { return await executeWorkbench(pythonCommand, args, undefined, input); } + if (CONFIGURED_SCAN_ROOT) { + const primary = await executeWorkbench(pythonCommand, [ + "resolve-scan-root", + ]); + if (!(await workbenchStoreExists(dirname(primary.scanRoot as string)))) { + const fallback = join(await scanRoot(), "workbench-state"); + if (await workbenchStoreExists(fallback)) { + fallbackWorkbenchStateDir = Promise.resolve(fallback); + return await executeWorkbench(pythonCommand, args, fallback, input); + } + } + } try { const result = await executeWorkbench( pythonCommand, @@ -2463,29 +2475,8 @@ async function executeWorkbenchWithStateSelection( undefined, input, ); - // Recover a known fallback after restart before reporting missing history. - if ( - args[0] === "finding-issues" && - input !== undefined && - JSON.parse(input.toString()).action === "inspect" && - result.storeExists === false - ) { - if (CONFIGURED_SCAN_ROOT) { - const stateDir = join(await scanRoot(), "workbench-state"); - const fallback = await executeWorkbench( - pythonCommand, - args, - stateDir, - input, - ); - if (fallback.storeExists === true) { - fallbackWorkbenchStateDir = Promise.resolve(stateDir); - return fallback; - } - } - } else { + if (result.storeExists !== false) persistentWorkbenchStateSucceeded = true; - } return result; } catch (error) { if (!isUnwritableSqliteOpenError(error)) throw error; @@ -2507,6 +2498,16 @@ async function executeWorkbenchWithStateSelection( }); } +async function workbenchStoreExists(stateDir: string): Promise { + try { + await fs.stat(join(stateDir, "workbench.sqlite3")); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + async function executeWorkbench( pythonCommand: string, args: string[], diff --git a/plugins/codex-security/mcp-app/tests/test_workbench_state_fallback.ts b/plugins/codex-security/mcp-app/tests/test_workbench_state_fallback.ts index 1ae485139c..b48ee4937e 100644 --- a/plugins/codex-security/mcp-app/tests/test_workbench_state_fallback.ts +++ b/plugins/codex-security/mcp-app/tests/test_workbench_state_fallback.ts @@ -253,6 +253,7 @@ async function testWorkbenchStateFallback() { const scanRoot = path.join(fixtureRoot, "fallback-scans"); const fallbackServer = startServer(serverBundlePath, { + CODEX_HOME: path.join(fixtureRoot, "fallback-home"), CODEX_SECURITY_SCAN_ROOT: scanRoot, CODEX_SECURITY_STATE_DIR: undefined, FAKE_PYTHON_ALWAYS_FAIL: undefined, @@ -399,7 +400,12 @@ async function testWorkbenchStateFallback() { issueIdentifier: "APP-1", operation: "create", }; - for (const existingStore of ["missing", "empty", "populated"]) { + for (const existingStore of [ + "missing", + "empty", + "populated", + "empty-write-first", + ]) { await writeFile(invocationLog, ""); const issueScanRoot = path.join( fixtureRoot, @@ -414,21 +420,29 @@ async function testWorkbenchStateFallback() { ); const receipts = existingStore === "populated" ? [issueReceipt] : []; if (existingStore !== "missing") { - execFileSync( - realPython, - [ - path.join(pluginRoot, "scripts", "workbench_db.py"), - "finding-issues", - ], - { - env: { ...process.env, CODEX_SECURITY_STATE_DIR: issueState }, - input: JSON.stringify({ - ...issueInput, - action: "record", - receipts, - }), - }, - ); + for (const state of [ + issueState, + path.join(issueScanRoot, "workbench-state"), + ]) { + execFileSync( + realPython, + [ + path.join(pluginRoot, "scripts", "workbench_db.py"), + "finding-issues", + ], + { + env: { ...process.env, CODEX_SECURITY_STATE_DIR: state }, + input: JSON.stringify({ + ...issueInput, + action: "record", + receipts: + state === issueState + ? receipts + : [{ ...issueReceipt, issueIdentifier: "FALLBACK-1" }], + }), + }, + ); + } } const issueServer = startServer(serverBundlePath, { CODEX_HOME: issueHome, @@ -438,12 +452,39 @@ async function testWorkbenchStateFallback() { FAKE_PYTHON_FAILURE: "sqlite3.OperationalError: unable to open database file", FAKE_PYTHON_LOG: invocationLog, - FAKE_PYTHON_PERSISTENT_SUCCESSES: "1", + FAKE_PYTHON_PERSISTENT_SUCCESSES: + existingStore === "empty-write-first" ? "0" : "1", FAKE_REAL_PYTHON: realPython, PYTHON: fakePythonPath, }); try { await initialize(issueServer, 1); + if (existingStore === "empty-write-first") { + assertNoError( + await issueServer.request(2, "tools/call", { + name: "record_codex_security_finding_issues", + arguments: { ...issueInput, receipts: [issueReceipt] }, + }), + ); + const saved = await issueServer.request(3, "tools/call", { + name: "get_codex_security_finding_issues", + arguments: issueInput, + }); + assertNoError(saved); + assert.deepEqual( + saved.result.structuredContent.receipts.map( + (receipt: { issueIdentifier: string }) => receipt.issueIdentifier, + ), + ["FALLBACK-1", "APP-1"], + ); + const fallbackStateDir = path.join(issueScanRoot, "workbench-state"); + assert.deepEqual( + (await readJsonLines(invocationLog)).map((entry) => entry.stateDir), + [null, fallbackStateDir, fallbackStateDir], + "A first write failure must retain the existing SQLite-error fallback.", + ); + continue; + } const inspected = await issueServer.request(2, "tools/call", { name: "get_codex_security_finding_issues", arguments: issueInput, @@ -459,7 +500,7 @@ async function testWorkbenchStateFallback() { ); assert.equal( await pathExists(path.join(issueScanRoot, "workbench-state")), - false, + existingStore !== "missing", "Inspecting absent fallback history must not create its directory.", ); const recorded = await issueServer.request(3, "tools/call", { @@ -480,7 +521,7 @@ async function testWorkbenchStateFallback() { ); assert.deepEqual( (await readJsonLines(invocationLog)).map((entry) => entry.stateDir), - [null, fallbackStateDir, null, fallbackStateDir, fallbackStateDir], + [null, null, fallbackStateDir, fallbackStateDir], ); } else { assertToolError(recorded, /unable to open database file/); @@ -488,7 +529,7 @@ async function testWorkbenchStateFallback() { (await readJsonLines(invocationLog)).map((entry) => entry.stateDir), [null, null], ); - assert.equal(await pathExists(fallbackStateDir), false); + assert.equal(await pathExists(fallbackStateDir), true); } assert.equal( issueServer @@ -500,41 +541,56 @@ async function testWorkbenchStateFallback() { await issueServer.stop(); } if (existingStore === "missing") { - const restarted = startServer(serverBundlePath, { - CODEX_HOME: issueHome, - CODEX_SECURITY_SCAN_ROOT: issueScanRoot, - CODEX_SECURITY_STATE_DIR: undefined, - PYTHON: realPython, - }); - try { - await initialize(restarted, 1); - const inspected = await restarted.request(2, "tools/call", { - name: "get_codex_security_finding_issues", - arguments: issueInput, - }); - assertNoError(inspected); - assert.deepEqual( - inspected.result.structuredContent.receipts.map( - (receipt: { issueIdentifier: string }) => receipt.issueIdentifier, - ), - ["APP-1"], - "Inspection after restart must recover the existing fallback receipts.", - ); - const reused = await restarted.request(3, "tools/call", { - name: "record_codex_security_finding_issues", - arguments: { - ...issueInput, - receipts: [{ ...issueReceipt, operation: "reuse" }], - }, + for (const firstOperation of ["inspect", "record"]) { + const restarted = startServer(serverBundlePath, { + CODEX_HOME: issueHome, + CODEX_SECURITY_SCAN_ROOT: issueScanRoot, + CODEX_SECURITY_STATE_DIR: undefined, + PYTHON: realPython, }); - assertNoError(reused); - assert.equal( - await pathExists(path.join(issueState, "workbench.sqlite3")), - false, - "Recording after restart must keep using the recovered fallback store.", - ); - } finally { - await restarted.stop(); + try { + await initialize(restarted, 1); + if (firstOperation === "record") { + assertNoError( + await restarted.request(2, "tools/call", { + name: "record_codex_security_finding_issues", + arguments: { + ...issueInput, + receipts: [{ ...issueReceipt, operation: "reuse" }], + }, + }), + ); + } + const inspected = await restarted.request(3, "tools/call", { + name: "get_codex_security_finding_issues", + arguments: issueInput, + }); + assertNoError(inspected); + assert.equal( + inspected.result.structuredContent.receipts.some( + (receipt: { issueIdentifier: string; operation: string }) => + receipt.issueIdentifier === "APP-1" && + receipt.operation === "create", + ), + true, + `${firstOperation}-first restart must recover the existing fallback receipt.`, + ); + const reused = await restarted.request(4, "tools/call", { + name: "record_codex_security_finding_issues", + arguments: { + ...issueInput, + receipts: [{ ...issueReceipt, operation: "reuse" }], + }, + }); + assertNoError(reused); + assert.equal( + await pathExists(path.join(issueState, "workbench.sqlite3")), + false, + "Recording after restart must keep using the recovered fallback store.", + ); + } finally { + await restarted.stop(); + } } } } @@ -583,6 +639,7 @@ async function testWorkbenchStateFallback() { await writeFile(invocationLog, ""); const genericScanRoot = path.join(fixtureRoot, "generic-scans"); const genericServer = startServer(serverBundlePath, { + CODEX_HOME: path.join(fixtureRoot, "generic-home"), CODEX_SECURITY_SCAN_ROOT: genericScanRoot, CODEX_SECURITY_STATE_DIR: undefined, FAKE_PYTHON_ALWAYS_FAIL: "1", From 1aadd7ac2d5991561879e1e8e00f32f8d780e70f Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 8 Oct 2026 02:52:08 +0000 Subject: [PATCH 04/11] fix(tracking): preserve fallback for inaccessible primary stores --- plugins/codex-security/mcp-app/server.ts | 12 +++-- .../tests/test_workbench_state_fallback.ts | 48 +++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/plugins/codex-security/mcp-app/server.ts b/plugins/codex-security/mcp-app/server.ts index b2085a63d8..75ef1c97b8 100644 --- a/plugins/codex-security/mcp-app/server.ts +++ b/plugins/codex-security/mcp-app/server.ts @@ -2460,9 +2460,11 @@ async function executeWorkbenchWithStateSelection( const primary = await executeWorkbench(pythonCommand, [ "resolve-scan-root", ]); - if (!(await workbenchStoreExists(dirname(primary.scanRoot as string)))) { + if ( + !(await workbenchStoreMayExist(dirname(primary.scanRoot as string))) + ) { const fallback = join(await scanRoot(), "workbench-state"); - if (await workbenchStoreExists(fallback)) { + if (await workbenchStoreMayExist(fallback)) { fallbackWorkbenchStateDir = Promise.resolve(fallback); return await executeWorkbench(pythonCommand, args, fallback, input); } @@ -2498,12 +2500,14 @@ async function executeWorkbenchWithStateSelection( }); } -async function workbenchStoreExists(stateDir: string): Promise { +async function workbenchStoreMayExist(stateDir: string): Promise { try { await fs.stat(join(stateDir, "workbench.sqlite3")); return true; } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") return false; + if (code === "EACCES" || code === "EPERM") return true; throw error; } } diff --git a/plugins/codex-security/mcp-app/tests/test_workbench_state_fallback.ts b/plugins/codex-security/mcp-app/tests/test_workbench_state_fallback.ts index b48ee4937e..2a1d94498e 100644 --- a/plugins/codex-security/mcp-app/tests/test_workbench_state_fallback.ts +++ b/plugins/codex-security/mcp-app/tests/test_workbench_state_fallback.ts @@ -163,6 +163,54 @@ async function testWorkbenchStateFallback() { } } + const inaccessibleHome = path.join(fixtureRoot, "inaccessible-home"); + const inaccessibleState = path.join( + inaccessibleHome, + "state", + "plugins", + "codex-security", + ); + const inaccessibleScanRoot = path.join(fixtureRoot, "inaccessible-scans"); + await mkdir(inaccessibleState, { recursive: true }); + await chmod(inaccessibleState, 0o000); + const inaccessibleServer = startServer(serverBundlePath, { + CODEX_HOME: inaccessibleHome, + CODEX_SECURITY_SCAN_ROOT: inaccessibleScanRoot, + CODEX_SECURITY_STATE_DIR: undefined, + PYTHON: realPython, + }); + try { + await assert.rejects( + stat(path.join(inaccessibleState, "workbench.sqlite3")), + { code: "EACCES" }, + ); + await initialize(inaccessibleServer, 1); + assertNoError( + await startPromptOnlyScan(inaccessibleServer, 2, targetPath), + ); + assert.equal( + ( + await stat( + path.join( + inaccessibleScanRoot, + "workbench-state", + "workbench.sqlite3", + ), + ) + ).isFile(), + true, + ); + assert.equal( + inaccessibleServer + .stderrEvents() + .filter((event) => event.event === "state_fallback_pinned").length, + 1, + ); + } finally { + await inaccessibleServer.stop(); + await chmod(inaccessibleState, 0o700); + } + const readFirstHome = path.join(fixtureRoot, "read-first-home"); const readFirstDefaultState = path.join( readFirstHome, From 2ba3cb85bca5600d2ca6fda8ec89f1a2cdac2f52 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 8 Oct 2026 03:07:13 +0000 Subject: [PATCH 05/11] fix(tracking): defer inaccessible receipt stores to SQLite --- .../tests/test_workbench_state_fallback.ts | 113 +++++++++++++++--- .../scripts/workbench_publication.py | 18 +-- .../tests/test_finding_issue_receipts.py | 32 +++++ 3 files changed, 139 insertions(+), 24 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/test_workbench_state_fallback.ts b/plugins/codex-security/mcp-app/tests/test_workbench_state_fallback.ts index 2a1d94498e..36ee1515f3 100644 --- a/plugins/codex-security/mcp-app/tests/test_workbench_state_fallback.ts +++ b/plugins/codex-security/mcp-app/tests/test_workbench_state_fallback.ts @@ -448,6 +448,19 @@ async function testWorkbenchStateFallback() { issueIdentifier: "APP-1", operation: "create", }; + const seedIssueStore = ( + state: string, + receipts: (typeof issueReceipt)[], + ) => { + execFileSync( + realPython, + [path.join(pluginRoot, "scripts", "workbench_db.py"), "finding-issues"], + { + env: { ...process.env, CODEX_SECURITY_STATE_DIR: state }, + input: JSON.stringify({ ...issueInput, action: "record", receipts }), + }, + ); + }; for (const existingStore of [ "missing", "empty", @@ -472,23 +485,11 @@ async function testWorkbenchStateFallback() { issueState, path.join(issueScanRoot, "workbench-state"), ]) { - execFileSync( - realPython, - [ - path.join(pluginRoot, "scripts", "workbench_db.py"), - "finding-issues", - ], - { - env: { ...process.env, CODEX_SECURITY_STATE_DIR: state }, - input: JSON.stringify({ - ...issueInput, - action: "record", - receipts: - state === issueState - ? receipts - : [{ ...issueReceipt, issueIdentifier: "FALLBACK-1" }], - }), - }, + seedIssueStore( + state, + state === issueState + ? receipts + : [{ ...issueReceipt, issueIdentifier: "FALLBACK-1" }], ); } } @@ -643,6 +644,84 @@ async function testWorkbenchStateFallback() { } } + if (process.getuid?.() !== 0) { + for (const firstOperation of ["inspect", "record"]) { + const receiptHome = path.join( + fixtureRoot, + `inaccessible-receipt-${firstOperation}-home`, + ); + const receiptState = path.join( + receiptHome, + "state", + "plugins", + "codex-security", + ); + const receiptScanRoot = path.join( + fixtureRoot, + `inaccessible-receipt-${firstOperation}-scans`, + ); + seedIssueStore(receiptState, []); + seedIssueStore(path.join(receiptScanRoot, "workbench-state"), [ + issueReceipt, + ]); + await chmod(receiptState, 0o000); + const receiptServer = startServer(serverBundlePath, { + CODEX_HOME: receiptHome, + CODEX_SECURITY_SCAN_ROOT: receiptScanRoot, + CODEX_SECURITY_STATE_DIR: undefined, + PYTHON: realPython, + }); + try { + await initialize(receiptServer, 1); + const recordReuse = (id: number) => + receiptServer.request(id, "tools/call", { + name: "record_codex_security_finding_issues", + arguments: { + ...issueInput, + receipts: [{ ...issueReceipt, operation: "reuse" }], + }, + }); + if (firstOperation === "record") assertNoError(await recordReuse(2)); + const inspected = await receiptServer.request(3, "tools/call", { + name: "get_codex_security_finding_issues", + arguments: issueInput, + }); + assertNoError(inspected); + assert.equal( + inspected.result.structuredContent.receipts.some( + (receipt: { issueIdentifier: string; operation: string }) => + receipt.issueIdentifier === "APP-1" && + receipt.operation === "create", + ), + true, + `${firstOperation}-first access must retain existing fallback receipts.`, + ); + assertNoError(await recordReuse(4)); + const saved = await receiptServer.request(5, "tools/call", { + name: "get_codex_security_finding_issues", + arguments: issueInput, + }); + assertNoError(saved); + assert.deepEqual( + saved.result.structuredContent.receipts.map( + (receipt: { operation: string }) => receipt.operation, + ), + ["create", "reuse"], + ); + assert.equal( + receiptServer + .stderrEvents() + .filter((event) => event.event === "state_fallback_pinned") + .length, + 1, + ); + } finally { + await receiptServer.stop(); + await chmod(receiptState, 0o700); + } + } + } + await writeFile(invocationLog, ""); const provenScanRoot = path.join(fixtureRoot, "proven-scans"); const provenServer = startServer(serverBundlePath, { diff --git a/plugins/codex-security/scripts/workbench_publication.py b/plugins/codex-security/scripts/workbench_publication.py index fe32965e94..cbaea4e44a 100644 --- a/plugins/codex-security/scripts/workbench_publication.py +++ b/plugins/codex-security/scripts/workbench_publication.py @@ -206,15 +206,19 @@ def finding_issues(db: Any, payload: Any) -> dict[str, Any]: "receipts": [], } path = db.database_path() - if not path.is_file(): - if require_history: - raise SystemExit( - "Cannot publish findings because the local Codex Security scan-history database " - "does not exist. Use the state directory where this scan was completed." - ) - if payload["action"] == "inspect": + if payload["action"] == "inspect" or require_history: + try: + path.stat() + except FileNotFoundError: + if require_history: + raise SystemExit( + "Cannot publish findings because the local Codex Security scan-history database " + "does not exist. Use the state directory where this scan was completed." + ) from None result["storeExists"] = False return result + except PermissionError: + pass # Let SQLite report the open failure used by the host's state fallback. if payload["action"] == "inspect": database_uri = f"file:{quote(str(path), safe='')}?mode=ro" connection = sqlite3.connect(database_uri, uri=True, timeout=5) diff --git a/plugins/codex-security/tests/test_finding_issue_receipts.py b/plugins/codex-security/tests/test_finding_issue_receipts.py index 5159f135bc..4de4dc512f 100644 --- a/plugins/codex-security/tests/test_finding_issue_receipts.py +++ b/plugins/codex-security/tests/test_finding_issue_receipts.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os import shutil import sqlite3 import uuid @@ -85,6 +86,37 @@ def test_inspection_of_portable_source_does_not_create_state(tmp_path: Path) -> assert not state.exists() +@pytest.mark.skipif( + os.name == "nt" or getattr(os, "geteuid", lambda: 0)() == 0, + reason="requires Unix file permissions", +) +@pytest.mark.parametrize("action", ["inspect", "record"]) +def test_inaccessible_state_preserves_sqlite_open_error(tmp_path: Path, action: str) -> None: + scan_dir, validated = source(tmp_path) + state = tmp_path / "state" + state.mkdir(mode=0o000) + try: + with pytest.raises(PermissionError): + (state / "workbench.sqlite3").stat() + result = run_workbench( + state, + "finding-issues", + input_text=json.dumps( + { + "action": action, + "scanDirectory": str(scan_dir), + "destination": LINEAR, + **({"receipts": [receipt_for(validated)]} if action == "record" else {}), + } + ), + check=False, + ) + assert result["returncode"] != 0 + assert "sqlite3.OperationalError: unable to open database file" in result["stderr"] + finally: + state.chmod(0o700) + + @pytest.mark.parametrize("project", [None, "project-example"]) def test_legacy_receipts_are_read_without_migration_and_preserved_on_upgrade( tmp_path: Path, project: str | None From 279d39584fed8489e9304b5de0e9d0e0ba664ce6 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 8 Oct 2026 07:09:06 +0000 Subject: [PATCH 06/11] refactor: derive publication membership from the sealed scan --- plugins/codex-security/mcp-app/server.ts | 18 ++--- .../scripts/workbench_publication.py | 4 - .../tests/test_finding_issue_receipts.py | 15 +++- sdk/typescript/src/publication-store.ts | 4 +- sdk/typescript/src/publication.ts | 12 --- .../tests-ts/publication-store.test.ts | 78 +++++++++++++++---- 6 files changed, 84 insertions(+), 47 deletions(-) diff --git a/plugins/codex-security/mcp-app/server.ts b/plugins/codex-security/mcp-app/server.ts index bcb6ff4481..7755d4ac9f 100644 --- a/plugins/codex-security/mcp-app/server.ts +++ b/plugins/codex-security/mcp-app/server.ts @@ -2436,13 +2436,13 @@ async function executeWorkbenchWithStateSelection( args: string[], input?: string | Buffer, ): Promise { - if (WORKBENCH_COMMANDS_WITHOUT_DATABASE.has(args[0] ?? "")) { - return await executeWorkbench(pythonCommand, args, undefined, input); - } - if (CONFIGURED_WORKBENCH_STATE_DIR) { + if ( + WORKBENCH_COMMANDS_WITHOUT_DATABASE.has(args[0] ?? "") || + CONFIGURED_WORKBENCH_STATE_DIR + ) { return await executeWorkbench(pythonCommand, args, undefined, input); } - if (fallbackWorkbenchStateDir) { + if (fallbackWorkbenchStateDir || persistentWorkbenchStateSucceeded) { return await executeWorkbench( pythonCommand, args, @@ -2450,11 +2450,8 @@ async function executeWorkbenchWithStateSelection( input, ); } - if (persistentWorkbenchStateSucceeded) { - return await executeWorkbench(pythonCommand, args, undefined, input); - } return await workbenchStateSelectionLock.run(async () => { - if (fallbackWorkbenchStateDir) { + if (fallbackWorkbenchStateDir || persistentWorkbenchStateSucceeded) { return await executeWorkbench( pythonCommand, args, @@ -2462,9 +2459,6 @@ async function executeWorkbenchWithStateSelection( input, ); } - if (persistentWorkbenchStateSucceeded) { - return await executeWorkbench(pythonCommand, args, undefined, input); - } if (CONFIGURED_SCAN_ROOT) { const primary = await executeWorkbench(pythonCommand, [ "resolve-scan-root", diff --git a/plugins/codex-security/scripts/workbench_publication.py b/plugins/codex-security/scripts/workbench_publication.py index cbaea4e44a..b741e11292 100644 --- a/plugins/codex-security/scripts/workbench_publication.py +++ b/plugins/codex-security/scripts/workbench_publication.py @@ -189,10 +189,6 @@ def finding_issues(db: Any, payload: Any) -> dict[str, Any]: ): raise SystemExit("Selected finding IDs must belong to the sealed scan.") selected = {finding_id: available[finding_id] for finding_id in finding_ids} - if require_history and selected != available: - raise SystemExit( - "The completed scan findings do not exactly match local Codex Security scan history." - ) receipts = ( validate_issue_receipts(payload.get("receipts"), selected) if payload["action"] == "record" diff --git a/plugins/codex-security/tests/test_finding_issue_receipts.py b/plugins/codex-security/tests/test_finding_issue_receipts.py index e50eed63b8..08bccff642 100644 --- a/plugins/codex-security/tests/test_finding_issue_receipts.py +++ b/plugins/codex-security/tests/test_finding_issue_receipts.py @@ -14,6 +14,11 @@ PLUGIN_ROOT = Path(__file__).resolve().parents[1] VALIDATOR = load_script("validate_scan_contract") SCHEMA = load_script("workbench_schema") +RECEIPT_MIGRATION = next( + version + for version, name, _ in SCHEMA.MIGRATIONS + if name == "share finding issue receipts across trackers" +) LINEAR = {"type": "linear", "teamId": "team-example"} JIRA = {"type": "jira", "cloudId": "site-example", "projectId": "10001"} @@ -55,7 +60,10 @@ def legacy_database(path: Path, validated: dict, destination: dict) -> None: with closing(sqlite3.connect(path)) as connection: connection.row_factory = sqlite3.Row SCHEMA.apply_migrations( - connection, SCHEMA.MIGRATIONS[:-1], lambda: "2026-01-01T00:00:00Z", lambda _: None + connection, + tuple(migration for migration in SCHEMA.MIGRATIONS if migration[0] < RECEIPT_MIGRATION), + lambda: "2026-01-01T00:00:00Z", + lambda _: None, ) receipt = receipt_for(validated) connection.execute( @@ -144,7 +152,10 @@ def test_legacy_receipts_are_read_without_migration_and_preserved_on_upgrade( assert prepared["storeExists"] is True assert issues(state, scan_dir, "inspect", destination=destination) == inspected with closing(sqlite3.connect(path)) as connection: - assert connection.execute("SELECT MAX(version) FROM schema_migrations").fetchone()[0] == 47 + assert ( + connection.execute("SELECT MAX(version) FROM schema_migrations").fetchone()[0] + == SCHEMA.MIGRATIONS[-1][0] + ) assert ( connection.execute( "SELECT 1 FROM sqlite_master WHERE name = 'finding_publications'" diff --git a/sdk/typescript/src/publication-store.ts b/sdk/typescript/src/publication-store.ts index 6a4929b1a4..5070cf2dae 100644 --- a/sdk/typescript/src/publication-store.ts +++ b/sdk/typescript/src/publication-store.ts @@ -86,9 +86,7 @@ async function findingIssues( scanDirectory: publication.scanDirectory, expectedScanId: publication.scanId, destination: publication.destination, - findingIds: (publication.sourceFindings ?? publication.issues).map( - ({ findingId }) => findingId, - ), + findingIds: publication.issues.map(({ findingId }) => findingId), requireHistory: true, ...(action === "record" ? { diff --git a/sdk/typescript/src/publication.ts b/sdk/typescript/src/publication.ts index 9895c5ba4b..058d52703e 100644 --- a/sdk/typescript/src/publication.ts +++ b/sdk/typescript/src/publication.ts @@ -57,11 +57,6 @@ export interface PreparedScanPublication { scanDirectory: string; destination: LinearPublicationDestination; issues: PreparedPublicationIssue[]; - /** Complete sealed membership, retained when only some findings become issues. */ - sourceFindings?: Pick< - PreparedPublicationIssue, - "findingId" | "occurrenceId" - >[]; } export function linearPublicationArguments( @@ -142,13 +137,6 @@ export async function prepareScanPublication( return { scanId, scanDirectory: canonicalScanDirectory, - ...(classification === undefined && options.findingIds === undefined - ? {} - : { - sourceFindings: contract.findings.findings.map( - ({ findingId, occurrenceId }) => ({ findingId, occurrenceId }), - ), - }), destination: { type: options.destination, teamId: options.teamId, diff --git a/sdk/typescript/tests-ts/publication-store.test.ts b/sdk/typescript/tests-ts/publication-store.test.ts index e78791d202..4329025a58 100644 --- a/sdk/typescript/tests-ts/publication-store.test.ts +++ b/sdk/typescript/tests-ts/publication-store.test.ts @@ -343,12 +343,15 @@ describe("read-only publication history", () => { expect(existsSync(missing.stateDirectory)).toBe(false); const fixture = await publicationFixture(); - databaseRows(fixture, "DROP TABLE finding_issue_receipts"); - databaseRows( + const version = databaseRows( fixture, - "DELETE FROM schema_migrations WHERE version >= ?", - [47], - ); + "SELECT version FROM schema_migrations WHERE name = ?", + ["share finding issue receipts across trackers"], + )[0]!["version"]; + databaseRows(fixture, "DROP TABLE finding_issue_receipts"); + databaseRows(fixture, "DELETE FROM schema_migrations WHERE version >= ?", [ + version, + ]); const database = join(fixture.stateDirectory, "workbench.sqlite3"); const before = await readFile(database); const mode = (await stat(database)).mode; @@ -366,7 +369,7 @@ describe("read-only publication history", () => { databaseRows( fixture, "SELECT version FROM schema_migrations WHERE version >= ?", - [47], + [version], ), ).toEqual([]); expect( @@ -495,17 +498,10 @@ describe("read-only publication history", () => { { ...fixture.publication, issues: [fixture.publication.issues[0]!], - sourceFindings: fixture.publication.issues, }, fixture.environment, ), ).resolves.toEqual([first]); - await expect( - inspectPublicationStore( - { ...fixture.publication, issues: [fixture.publication.issues[0]!] }, - fixture.environment, - ), - ).rejects.toThrow(/finding|match/u); }); test("includes committed associations that are still in the WAL", async () => { @@ -541,6 +537,59 @@ describe("read-only publication history", () => { }); describe("persisted finding publication associations", () => { + test("persists only the selected sealed findings while retaining complete scan history", async () => { + const fixture = await publicationFixture(); + const first = publishedIssue(fixture.publication, 0); + const excluded = publishedIssue(fixture.publication, 1); + const selected = await prepareScanPublication( + fixture.publication.scanDirectory, + { + destination: "linear", + teamId: fixture.publication.destination.teamId, + projectId: fixture.publication.destination.projectId, + environment: fixture.environment, + findingIds: [first.findingId], + }, + ); + + await expect( + inspectPublicationStore(selected, fixture.environment), + ).resolves.toEqual([]); + await preparePublicationStore(selected, fixture.environment); + await expect( + recordPublishedIssues(selected, [first, excluded], fixture.environment), + ).rejects.toThrow(/selected sealed findings/u); + expect( + databaseRows( + fixture, + "SELECT COUNT(*) AS count FROM finding_issue_receipts", + ), + ).toEqual([{ count: 0 }]); + await expect( + recordPublishedIssues(selected, [first], fixture.environment), + ).resolves.toEqual([first]); + await expect( + inspectPublicationStore(selected, fixture.environment), + ).resolves.toEqual([first]); + + databaseRows(fixture, "DELETE FROM finding_occurrences WHERE id = ?", [ + excluded.occurrenceId, + ]); + for (const action of [ + () => inspectPublicationStore(selected, fixture.environment), + () => preparePublicationStore(selected, fixture.environment), + () => recordPublishedIssues(selected, [first], fixture.environment), + ]) { + await expect(action()).rejects.toThrow(/do not exactly match local/u); + } + expect( + databaseRows( + fixture, + "SELECT COUNT(*) AS count FROM finding_issue_receipts", + ), + ).toEqual([{ count: 1 }]); + }); + test("reads legacy Linear receipts without migration and preserves them when preparing shared history", async () => { const fixture = await publicationFixture({ count: 1, @@ -556,7 +605,8 @@ describe("persisted finding publication associations", () => { "from workbench_schema import MIGRATIONS, apply_migrations", "connection = sqlite3.connect(sys.argv[2])", "connection.row_factory = sqlite3.Row", - "apply_migrations(connection, tuple(item for item in MIGRATIONS if item[0] <= 42), lambda: '2026-08-01T00:00:00Z', lambda _: None)", + "receipt_version = next(version for version, name, _ in MIGRATIONS if name == 'share finding issue receipts across trackers')", + "apply_migrations(connection, tuple(item for item in MIGRATIONS if item[0] < receipt_version), lambda: '2026-08-01T00:00:00Z', lambda _: None)", "connection.close()", ].join("\n"), join(PLUGIN_ROOT, "scripts"), From 861534633e953e5b4e0988966694f8b38014ba72 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 9 Oct 2026 13:16:47 +0000 Subject: [PATCH 07/11] Preserve accepted receipt URLs and complete receipt diagnostics --- plugins/codex-security/mcp-app/server.ts | 6 +++-- .../mcp-app/tests/test_finding_issue_tools.ts | 5 +++- .../scripts/workbench_publication.py | 2 +- .../tests/test_finding_issue_receipts.py | 26 +++++++++++++++++++ 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/plugins/codex-security/mcp-app/server.ts b/plugins/codex-security/mcp-app/server.ts index 75f7da3ea7..96ec810c6a 100644 --- a/plugins/codex-security/mcp-app/server.ts +++ b/plugins/codex-security/mcp-app/server.ts @@ -2394,8 +2394,10 @@ async function executeWorkbench( ? { ...process.env, CODEX_SECURITY_STATE_DIR: stateDir } : process.env, encoding: "utf8" as const, - // Artifact bytes are base64-encoded here; retain the existing file-size behavior. - maxBuffer: args[0] === "read-artifact" ? Infinity : 4 * 1024 * 1024, + // Preserve complete artifact bytes and issue receipt diagnostics/history. + maxBuffer: ["read-artifact", "finding-issues"].includes(args[0] ?? "") + ? Infinity + : 4 * 1024 * 1024, timeout, }, ); diff --git a/plugins/codex-security/mcp-app/tests/test_finding_issue_tools.ts b/plugins/codex-security/mcp-app/tests/test_finding_issue_tools.ts index db272863bd..bee0865e2b 100644 --- a/plugins/codex-security/mcp-app/tests/test_finding_issue_tools.ts +++ b/plugins/codex-security/mcp-app/tests/test_finding_issue_tools.ts @@ -80,7 +80,10 @@ try { assert.equal(accepted.receipts.length, 1); assert.equal(accepted.receipts[0].issueIdentifier, "APP-1"); assert.equal(accepted.receipts[0].readback, undefined); - const readback = { status: "failed", error: "Issue read access denied." }; + const readback = { + status: "failed", + error: `Issue read access denied. ${"x".repeat(4 * 1024 * 1024)}`, + }; await call("record_codex_security_finding_issues", { ...source, destination: jira, diff --git a/plugins/codex-security/scripts/workbench_publication.py b/plugins/codex-security/scripts/workbench_publication.py index 2fbe7a382a..7abccf36bb 100644 --- a/plugins/codex-security/scripts/workbench_publication.py +++ b/plugins/codex-security/scripts/workbench_publication.py @@ -261,7 +261,7 @@ def finding_issues(db: Any, payload: Any) -> dict[str, Any]: ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (occurrence_id, destination_key, external_id, operation) DO UPDATE SET - external_url = COALESCE(excluded.external_url, external_url), + external_url = COALESCE(external_url, excluded.external_url), readback_json = COALESCE(excluded.readback_json, readback_json), updated_at = excluded.updated_at """, diff --git a/plugins/codex-security/tests/test_finding_issue_receipts.py b/plugins/codex-security/tests/test_finding_issue_receipts.py index 65861c35aa..e835cf884c 100644 --- a/plugins/codex-security/tests/test_finding_issue_receipts.py +++ b/plugins/codex-security/tests/test_finding_issue_receipts.py @@ -216,6 +216,32 @@ def test_records_id_only_acceptance_and_failed_readback_without_losing_create( assert connection.execute("SELECT COUNT(*) FROM scans").fetchone()[0] == 0 +@pytest.mark.parametrize("operation", ["create", "update", "reuse"]) +def test_readback_replay_preserves_the_accepted_receipt_url(tmp_path: Path, operation: str) -> None: + scan_dir, validated = source(tmp_path) + state = tmp_path / "state" + accepted = receipt_for( + validated, operation=operation, url="https://issues.example.test/browse/SEC-101" + ) + issues(state, scan_dir, "record", receipts=[accepted]) + readback = {"status": "verified"} + replayed = issues( + state, + scan_dir, + "record", + receipts=[ + {**accepted, "url": "https://issues.example.test/browse/SEC-999", "readback": readback} + ], + ) + expected = { + **accepted, + "scanId": validated["manifest"]["scan"]["id"], + "readback": readback, + } + assert replayed["receipts"] == [expected] + assert issues(state, scan_dir, "inspect")["receipts"] == [expected] + + @pytest.mark.parametrize( "destination", [ From d9a7671eb767d63c5d3bba04dd56bb67adce805b Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 9 Oct 2026 13:32:05 +0000 Subject: [PATCH 08/11] Index finding issue ownership checks without rewriting migrations --- .../shared/workbench-migrations.json | 7 ++ .../test_workbench_setup_and_migrations.py | 65 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/plugins/codex-security/shared/workbench-migrations.json b/plugins/codex-security/shared/workbench-migrations.json index 3b35cdabee..11e3682864 100644 --- a/plugins/codex-security/shared/workbench-migrations.json +++ b/plugins/codex-security/shared/workbench-migrations.json @@ -403,5 +403,12 @@ "DROP TABLE finding_publications;", "CREATE INDEX finding_issue_receipts_by_finding\nON finding_issue_receipts(finding_id, destination_key, id);" ] + }, + { + "version": 49, + "name": "index finding issue receipt ownership", + "statements": [ + "CREATE INDEX finding_issue_receipts_by_destination_issue\nON finding_issue_receipts(destination_key, external_id, finding_id);" + ] } ] diff --git a/plugins/codex-security/tests/test_workbench_setup_and_migrations.py b/plugins/codex-security/tests/test_workbench_setup_and_migrations.py index 2a197fc65b..a7e9599923 100644 --- a/plugins/codex-security/tests/test_workbench_setup_and_migrations.py +++ b/plugins/codex-security/tests/test_workbench_setup_and_migrations.py @@ -90,6 +90,7 @@ (46, "invalidate local embeddings when finding bodies change"), (47, "snapshot deep scan discovery context"), (48, "share finding issue receipts across trackers"), + (49, "index finding issue receipt ownership"), ] @@ -1227,6 +1228,7 @@ def test_workbench_reconciles_monorepo_migration_lineage() -> None: ) } >= { "finding_issue_receipts_by_finding", + "finding_issue_receipts_by_destination_issue", } assert [ row["name"] @@ -1240,6 +1242,69 @@ def test_workbench_reconciles_monorepo_migration_lineage() -> None: ] == ["publication_error_message"] +def test_workbench_indexes_issue_ownership_without_changing_existing_receipts() -> None: + connection, apply_migrations = create_historical_database(49) + with closing(connection): + timestamp = "2026-01-01T00:00:00Z" + connection.executemany( + "INSERT INTO finding_issue_receipts (scan_id, finding_id, occurrence_id, " + "destination_key, external_id, external_url, operation, readback_json, " + "created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [ + ( + f"scan-{index}", + f"finding-{index}", + f"occurrence-{index}", + f"destination-{index % 2}", + f"ISSUE-{index}", + f"https://issues.example.test/browse/ISSUE-{index}", + "create", + '{"status":"verified"}', + timestamp, + timestamp, + ) + for index in range(256) + ], + ) + connection.commit() + receipts = connection.execute("SELECT * FROM finding_issue_receipts ORDER BY id").fetchall() + history = connection.execute("SELECT * FROM schema_migrations ORDER BY version").fetchall() + + apply_migrations(connection) + apply_migrations(connection) + + assert ( + connection.execute("SELECT * FROM finding_issue_receipts ORDER BY id").fetchall() + == receipts + ) + assert ( + connection.execute( + "SELECT * FROM schema_migrations WHERE version < 49 ORDER BY version" + ).fetchall() + == history + ) + query = ( + "SELECT 1 FROM finding_issue_receipts WHERE destination_key = ? " + "AND external_id = ? AND finding_id != ? LIMIT 1" + ) + parameters = ("destination-1", "ISSUE-129", "another-finding") + plan = connection.execute("EXPLAIN QUERY PLAN " + query, parameters).fetchall() + assert any( + row[3].startswith("SEARCH ") + and "COVERING INDEX finding_issue_receipts_by_destination_issue" in row[3] + for row in plan + ), [tuple(row) for row in plan] + assert connection.execute(query, parameters).fetchone()[0] == 1 + assert ( + connection.execute(query, ("destination-1", "ISSUE-129", "finding-129")).fetchone() + is None + ) + assert ( + connection.execute(query, ("destination-0", "ISSUE-129", "another-finding")).fetchone() + is None + ) + + def test_workbench_creates_single_final_schema(tmp_path: Path) -> None: state_dir = tmp_path / "state" run_workbench(state_dir, "database-info") From aa9c4b1e5fa419f5c7ce9cc05e5881e93524056c Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 10 Oct 2026 03:13:16 +0000 Subject: [PATCH 09/11] test(deep-scan): isolate the Node capability bundle probe --- sdk/typescript/tests-ts/api-direct-deep-scan.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/sdk/typescript/tests-ts/api-direct-deep-scan.test.ts b/sdk/typescript/tests-ts/api-direct-deep-scan.test.ts index 45b4512aa4..95daf13bc0 100644 --- a/sdk/typescript/tests-ts/api-direct-deep-scan.test.ts +++ b/sdk/typescript/tests-ts/api-direct-deep-scan.test.ts @@ -13,6 +13,7 @@ import { scanRuntimeDependencies, } from "./support/api-events.js"; import { createApiTestFixtures } from "./support/temporary-directories.js"; +import { runTestInSubprocess } from "./support/test-subprocess.js"; const { temporaryDirectory, cleanup } = createApiTestFixtures(); afterEach(cleanup); @@ -220,6 +221,13 @@ test.each(["configured", "managed"] as const)( ); test("Node retains the direct Deep Scan capability", async () => { + if ( + runTestInSubprocess( + import.meta.path, + "Node retains the direct Deep Scan capability", + ) + ) + return; const root = await temporaryDirectory(); const source = new URL("../src/deep-scan.ts", import.meta.url); const built = await Bun.build({ From e6e33bec5fa4d77bb47d0fa582f7284fe2a89ae7 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 10 Oct 2026 03:12:22 +0000 Subject: [PATCH 10/11] test(package): adapt Deep Scan fixture to direct engine --- .../scripts/fixtures/package-deep-codex.mjs | 37 +++++++++++++++ .../scripts/fixtures/package-deep-scan.mjs | 47 ++++++------------- 2 files changed, 51 insertions(+), 33 deletions(-) diff --git a/sdk/typescript/scripts/fixtures/package-deep-codex.mjs b/sdk/typescript/scripts/fixtures/package-deep-codex.mjs index c57ba26ae7..8e313e33e6 100644 --- a/sdk/typescript/scripts/fixtures/package-deep-codex.mjs +++ b/sdk/typescript/scripts/fixtures/package-deep-codex.mjs @@ -27,6 +27,8 @@ async function run() { const args = process.argv.slice(2); if (args.includes("app-server")) { await trace({ phase: "preflight", args }); + const threadId = "package-sdk-owner"; + const sessionPath = join(process.env.CODEX_HOME, `${threadId}.jsonl`); for await (const line of createInterface({ input: process.stdin })) { const message = JSON.parse(line); if (message.id === undefined) continue; @@ -35,6 +37,41 @@ async function run() { case "initialize": result = { userAgent: "package-fixture" }; break; + case "thread/start": + assert.equal(message.params.threadSource, "security_scan"); + assert.equal(message.params.ephemeral, false); + result = { + thread: { id: threadId, path: sessionPath }, + model: "gpt-5.5", + reasoningEffort: "high", + }; + break; + case "thread/inject_items": + assert.equal(message.params.threadId, threadId); + assert.equal(message.params.items[0].role, "user"); + await writeFile( + sessionPath, + `${JSON.stringify({ + type: "turn_context", + payload: { + permission_profile: { + type: "managed", + file_system: { + type: "restricted", + entries: [ + { + path: { type: "special", value: { kind: "root" } }, + access: "read", + }, + ], + }, + network: "restricted", + }, + }, + })}\n`, + ); + result = {}; + break; case "config/read": result = { config: { diff --git a/sdk/typescript/scripts/fixtures/package-deep-scan.mjs b/sdk/typescript/scripts/fixtures/package-deep-scan.mjs index 4abe15ee97..e62b5b0392 100644 --- a/sdk/typescript/scripts/fixtures/package-deep-scan.mjs +++ b/sdk/typescript/scripts/fixtures/package-deep-scan.mjs @@ -62,6 +62,11 @@ try { if (process.platform === "win32") await copyFile(process.execPath, executable); await chmod(executable, 0o700); + if (process.platform === "win32") { + // The direct SDK engine launches its preflight from this process too. + process.env.PACKAGE_DEEP_EXECUTABLE = executable; + await import(pathToFileURL(join(root, "package-deep-spawn.mjs")).href); + } await runInstalledSdk(installedPlugin, executable); await runDetachedPlugin(detachedPlugin, executable); @@ -386,43 +391,16 @@ async function runInstalledSdk(pluginRoot, executable) { version: manifest.version, }, }), - // Replace only the parent model's tool choice. The installed SDK registers - // and finalizes the scan; the packaged MCP runs the real Deep lifecycle. - createCodex({ env, apiKey }) { + // The installed SDK runs the direct engine without a parent model turn. + createCodex() { return { startThread() { return { id: owner, async runStreamed() { - return { - events: (async function* () { - yield { type: "thread.started", thread_id: owner }; - scanId = env.CODEX_SECURITY_SCAN_ID; - // The pinned SDK maps its apiKey option to this child variable. - const rpc = await server(f, { - ...env, - ...(apiKey ? { CODEX_API_KEY: apiKey } : {}), - }); - try { - const result = await rpc.call( - "start_codex_security_deep_scan", - { scanId }, - metadata(f, owner), - ); - await assertDraft(result.manifestPath); - } finally { - await rpc.close(); - } - yield { - type: "turn.completed", - usage: { - input_tokens: 1, - cached_input_tokens: 0, - output_tokens: 1, - }, - }; - })(), - }; + assert.fail( + "The direct engine must not start a parent model turn.", + ); }, }; }, @@ -439,6 +417,9 @@ async function runInstalledSdk(pluginRoot, executable) { maxDiscoveryRuns: 2, stopAfterNoNew: 1, outputDir: join(f.directory, "output"), + onScanRegistered(scan) { + scanId = scan.scanId; + }, }); assert.equal(result.threadId, owner); assert.equal(result.manifest.scan.status, "completed"); @@ -452,7 +433,7 @@ async function runInstalledSdk(pluginRoot, executable) { } finally { await client.close(); } - await assertExecutions(f, scanId, 4); + await assertExecutions(f, scanId, 5); } async function assertDraft(path) { From fd036f9f6e0aa2133ddd3610d460875a97d2b85b Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 10 Oct 2026 03:40:27 +0000 Subject: [PATCH 11/11] fix(tracking): allow full-scan receipt work to complete --- plugins/codex-security/mcp-app/server.ts | 1 + plugins/codex-security/mcp-app/tests/test_workbench_process.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/plugins/codex-security/mcp-app/server.ts b/plugins/codex-security/mcp-app/server.ts index 0a6c420a18..eb4f7090fb 100644 --- a/plugins/codex-security/mcp-app/server.ts +++ b/plugins/codex-security/mcp-app/server.ts @@ -2202,6 +2202,7 @@ async function executeWorkbench( "complete-scan", "export-findings", "finish-deep-scan", + "finding-issues", "get-scan", "get-deep-scan", "get-workspace", diff --git a/plugins/codex-security/mcp-app/tests/test_workbench_process.ts b/plugins/codex-security/mcp-app/tests/test_workbench_process.ts index 7521156c65..eaa52812ff 100644 --- a/plugins/codex-security/mcp-app/tests/test_workbench_process.ts +++ b/plugins/codex-security/mcp-app/tests/test_workbench_process.ts @@ -56,6 +56,7 @@ try { "fail-scan", "preserve-scan-results", "complete-scan", + "finding-issues", "start-prompt-only-scan", ]) { await executeWorkbench("fixture-python", [command, "--scan-id", "fixture"]);