Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
60ad828
feat!: share finding issue receipts across publication and tracking
codex Oct 8, 2026
b001ea2
fix(tracking): recover issue receipts after plugin restart
codex Oct 8, 2026
34f8e16
fix(tracking): recover stored history before initial writes
codex Oct 8, 2026
1aadd7a
fix(tracking): preserve fallback for inaccessible primary stores
codex Oct 8, 2026
2ba3cb8
fix(tracking): defer inaccessible receipt stores to SQLite
codex Oct 8, 2026
623d9a7
Merge main and append shared issue receipt migration
codex Oct 8, 2026
9bb06a1
Merge main migration test expectations
codex Oct 8, 2026
cf868ac
Merge main and append shared receipt migration after embedding history
codex Oct 8, 2026
279d395
refactor: derive publication membership from the sealed scan
codex Oct 8, 2026
6dc00c3
Merge main and preserve ordered finding receipt migrations
mldangelo-oai Oct 9, 2026
040a7c7
Preserve receipt ownership and integrate current workbench handling
mldangelo-oai Oct 9, 2026
d6c31ba
Integrate main publication refactoring with shared receipts
mldangelo-oai Oct 9, 2026
0c08651
Merge main and preserve shared receipt publication diagnostics
mldangelo-oai Oct 9, 2026
8615346
Preserve accepted receipt URLs and complete receipt diagnostics
mldangelo-oai Oct 9, 2026
d9a7671
Index finding issue ownership checks without rewriting migrations
mldangelo-oai Oct 9, 2026
d1fe058
Merge commit '553714fa07a133ccc37bcde018179ad6e7ed075c' into HEAD
mldangelo-oai Oct 9, 2026
117e94e
Merge main and retain current workbench runtime behavior
mldangelo-oai Oct 10, 2026
aa9c4b1
test(deep-scan): isolate the Node capability bundle probe
mldangelo-oai Oct 10, 2026
e6e33be
test(package): adapt Deep Scan fixture to direct engine
mldangelo-oai Oct 10, 2026
fd036f9
fix(tracking): allow full-scan receipt work to complete
mldangelo-oai Oct 10, 2026
85b3369
chore: merge main and resolve integration conflicts
mldangelo-oai Oct 11, 2026
5da54aa
chore: merge main into shared issue receipts
codex Oct 13, 2026
2dba6b2
Merge main and retain shared issue receipt integrity
codex Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/plugin-connectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions plugins/codex-security/mcp-app/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,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 {
DeepScanCoordinatorRegistry,
AsyncLock,
Expand Down Expand Up @@ -1811,6 +1812,7 @@ export function createCodexSecurityServer(): McpServer {
),
);

registerFindingIssueTools(server, runWorkbench);
registerCompactArtifactTools(server, {
runWorkbench,
pluginRoot: PLUGIN_ROOT,
Expand Down
96 changes: 96 additions & 0 deletions plugins/codex-security/mcp-app/src/server/finding-issue-tools.ts
Original file line number Diff line number Diff line change
@@ -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(),
Comment thread
mldangelo-oai marked this conversation as resolved.
})
.strict();

export function registerFindingIssueTools(
server: McpServer,
runWorkbench: (
args: string[],
input?: string | Buffer,
) => Promise<JsonObject>,
): void {
const meta = { ui: { visibility: ["model"] } };
const execute = async (action: "inspect" | "record", input: object) => {
const result = await runWorkbench(
["finding-issues"],
JSON.stringify({ action, ...input }),
Comment thread
mldangelo-oai marked this conversation as resolved.
);
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),
);
}
1 change: 1 addition & 0 deletions plugins/codex-security/mcp-app/src/workbench-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ async function executeWorkbench(
"complete-scan",
"export-findings",
"finish-deep-scan",
"finding-issues",
"get-scan",
"get-deep-scan",
"get-workspace",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1199,6 +1199,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}.`);
Expand Down
171 changes: 171 additions & 0 deletions plugins/codex-security/mcp-app/tests/test_finding_issue_tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
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<string, string>,
}),
);
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. ${"x".repeat(4 * 1024 * 1024)}`,
};
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<string, unknown>) {
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 };
}[];
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand Down
7 changes: 1 addition & 6 deletions plugins/codex-security/scripts/workbench_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,12 +299,7 @@ def add_command(name: str, *required: str) -> argparse.ArgumentParser:
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",
):
add_command(command, "--input-file")
subparsers.add_parser("finding-issues")

subparsers.add_parser("database-info")
subparsers.add_parser("finding-workflow")
Expand Down
19 changes: 3 additions & 16 deletions plugins/codex-security/scripts/workbench_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -3359,10 +3359,8 @@ def main(*, before_archive: Callable[[], None] | None = None) -> None:
if args.command in {"save-artifact", "read-artifact"}:
print(json.dumps(saved_results.read_or_save_artifact(args)))
return
if args.command == "inspect-linear-publication":
result = publication.inspect_linear_publication(
_WORKBENCH_PUBLICATION_CONTEXT, read_json_object(Path(args.input_file))
)
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(
Expand Down Expand Up @@ -3415,21 +3413,10 @@ def main(*, before_archive: Callable[[], None] | None = None) -> None:
"attach-scan-continuation-thread": partial(
handoff.attach_scan_continuation_thread, command_context
),
"prepare-linear-publication": partial(
publication.prepare_linear_publication, _WORKBENCH_PUBLICATION_CONTEXT
),
"record-linear-publications": partial(
publication.record_linear_publications, _WORKBENCH_PUBLICATION_CONTEXT
),
"export-findings": partial(publication.export_findings, _WORKBENCH_PUBLICATION_CONTEXT),
}
if handler := handlers.get(args.command):
payload = (
read_json_object(Path(args.input_file))
if args.command in {"prepare-linear-publication", "record-linear-publications"}
else args
)
result = handler(connection, payload)
result = handler(connection, args)
elif args.command == "get-workspace":
result = workspace_state(
connection,
Expand Down
Loading
Loading