Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
37 changes: 34 additions & 3 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -579,13 +579,40 @@ npx @openai/codex-security publish scan \
```

Destination flags take precedence over `CODEX_SECURITY_LINEAR_TEAM` and the
optional `CODEX_SECURITY_LINEAR_PROJECT`. Use `--dry-run` to preview the issue
titles without creating them, or `--json` to return structured publication
results.
optional `CODEX_SECURITY_LINEAR_PROJECT`. Use `--dry-run --json` to preview the
issue content and destination without creating issues, or `--json` to return
structured publication results.
Interactive publication shows a full-screen activity view with live Codex
output and issue-creation progress. Other terminals receive plain progress on
stderr, so `--json` output remains machine-readable.

Repeat `--finding FINDING_ID` to publish only selected findings. Omit it to
publish every finding. To publish exactly what you reviewed, copy the
`payloadDigest` from a dry run and pass it as `--expect-digest` with the same
selection and destination:

```bash
npx @openai/codex-security publish scan /path/to/completed-scan \
--to linear --linear-team TEAM_ID --finding csf_example \
--dry-run --json

npx @openai/codex-security publish scan /path/to/completed-scan \
--to linear --linear-team TEAM_ID --finding csf_example \
--expect-digest DIGEST_FROM_PREVIEW
```

The digest covers the selected issue content, destination, scan identity, and
requested assignee. A mismatch stops publication before any local publication
state or Linear issues are created. When an assignee is selected, the digest
uses HMAC-SHA-256 keyed by the selected Linear API credential. Keep the same
assignee and credential when publishing; changing either requires a new preview.
Unassigned previews remain credential-independent. Previews do not echo
assignee identities or credentials. The digest is not a permissions check or a
remote readback.
Keep saved previews private: they contain the full finding descriptions and
source snippets. Descriptions omit a wall-clock upload timestamp so an unchanged
scan and selection produce the same preview; Linear records issue creation time.

By default, publishing starts Codex with your existing Codex configuration and
connected Linear app. Sign in to Codex and connect Linear before publishing in
this mode. No separate Linear API token is required, and publication does not
Expand Down Expand Up @@ -653,6 +680,10 @@ console.log(publication.created.length);
Add `projectId: "PROJECT_ID"` to the options to publish into a specific Linear
project instead of directly to the team.

Use `findingIds: ["csf_example"]` to select findings. A call with `dryRun: true`
returns the selected `issues` and `payloadDigest`; pass that digest as
`expectedDigest` on the publishing call to reject changes since review.

Pass `linearApiKey` to publish directly through the Linear API. Omit
`assigneeId` to leave issues unassigned, or supply a Linear user ID or email
address to select an assignee:
Expand Down
145 changes: 116 additions & 29 deletions sdk/typescript/scripts/smoke-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -445,43 +445,92 @@ try {
assert.equal(publication.counts.findings, 1);
assert.equal(publication.counts.created, 0);
assert.match(publication.issues[0].title, /^\[Codex Security\]\[HIGH\] /u);
assert.match(publication.payloadDigest, /^[a-f0-9]{64}$/u);
assert.doesNotMatch(publication.issues[0].description, /\*\*Uploaded:\*\*/u);

const networkGuard = join(consumer, "reject-publication-network.cjs");
await writeFile(
networkGuard,
'globalThis.fetch = async () => { throw new Error("Publication dry runs must not make network requests."); };\n',
);
const selectedPublicationArgs = [
"--require",
networkGuard,
launcher,
"publish",
"scan",
publicationScan,
"--to",
"linear",
"--linear-team",
"team-example",
"--finding",
publication.issues[0].findingId,
"--expect-digest",
publication.payloadDigest,
];
const selectedPublicationOptions = {
cwd: consumer,
capture: true,
env: {
...process.env,
CODEX_SECURITY_LINEAR_PROJECT: "",
CODEX_SECURITY_LINEAR_API_KEY: "",
CODEX_SECURITY_STATE_DIR: join(consumer, "publication-state"),
},
};
assert.deepEqual(
JSON.parse(
run(
process.execPath,
[...selectedPublicationArgs, "--dry-run", "--json"],
selectedPublicationOptions,
),
),
publication,
);
assert.throws(
() =>
run(
process.execPath,
[...selectedPublicationArgs.slice(0, -1), "0".repeat(64), "--json"],
selectedPublicationOptions,
),
/does not match the expected digest/u,
);
const directPublicationArgs = [
"--require",
networkGuard,
launcher,
"publish",
"scan",
publicationScan,
"--to",
"linear",
"--linear-team",
"team-example",
"--project",
"project-example",
"--linear-api-key",
"lin_api_SYNTHETIC_INSTALLED_OVERRIDE",
"--linear-assignee",
"security@example.test",
"--dry-run",
"--json",
];
const directPublicationOptions = {
cwd: consumer,
capture: true,
env: {
...process.env,
CODEX_SECURITY_STATE_DIR: join(consumer, "publication-state"),
CODEX_SECURITY_LINEAR_API_KEY: "lin_api_SYNTHETIC_INSTALLED_ENV",
},
};
const directPublicationText = run(
process.execPath,
[
"--require",
networkGuard,
launcher,
"publish",
"scan",
publicationScan,
"--to",
"linear",
"--linear-team",
"team-example",
"--project",
"project-example",
"--linear-api-key",
"lin_api_SYNTHETIC_INSTALLED_OVERRIDE",
"--linear-assignee",
"security@example.test",
"--dry-run",
"--json",
],
{
cwd: consumer,
capture: true,
env: {
...process.env,
CODEX_SECURITY_STATE_DIR: join(consumer, "publication-state"),
CODEX_SECURITY_LINEAR_API_KEY: "lin_api_SYNTHETIC_INSTALLED_ENV",
},
},
directPublicationArgs,
directPublicationOptions,
);
const directPublication = JSON.parse(directPublicationText);
assert.equal(directPublication.scanId, publication.scanId);
Expand All @@ -492,6 +541,44 @@ try {
directPublicationText,
/lin_api_|security@example\.test/u,
);
assert.deepEqual(
JSON.parse(
run(
process.execPath,
[
...directPublicationArgs,
"--expect-digest",
directPublication.payloadDigest,
],
directPublicationOptions,
),
),
directPublication,
);
const rotatedPublicationArgs = [...directPublicationArgs];
rotatedPublicationArgs[
rotatedPublicationArgs.indexOf("--linear-api-key") + 1
] = "lin_api_SYNTHETIC_INSTALLED_ROTATED";
const rotatedPublication = JSON.parse(
run(process.execPath, rotatedPublicationArgs, directPublicationOptions),
);
assert.notEqual(
rotatedPublication.payloadDigest,
directPublication.payloadDigest,
);
assert.throws(
() =>
run(
process.execPath,
[
...rotatedPublicationArgs,
"--expect-digest",
directPublication.payloadDigest,
],
directPublicationOptions,
),
/does not match the expected digest/u,
);

await smokeNestedDeepScanWorker(installedRoot, consumer);

Expand Down
20 changes: 19 additions & 1 deletion sdk/typescript/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,8 @@ const VALUE_OPTIONS = new Set([
"--linear-api-key",
"--project",
"--linear-assignee",
"--finding",
"--expect-digest",
]);
const PROVIDER_OPTION = z
.enum(["openai", "openrouter", "fireworks", "amazon-bedrock"])
Expand Down Expand Up @@ -1547,7 +1549,8 @@ export async function main(
const publication = Cli.create("publish", {
description: "Publish completed Codex Security scan findings.",
}).command("scan", {
description: "Publish every finding from a completed scan to Linear.",
description:
"Publish selected or all findings from a completed scan to Linear.",
destructive: true,
mcp: false,
args: z.object({
Expand Down Expand Up @@ -1576,6 +1579,15 @@ export async function main(
.describe(
"Linear assignee email or user ID; omit to leave issues unassigned.",
),
finding: z
.array(optionValue("--finding"))
.optional()
.describe(
"Finding ID to publish; repeat to select several. Defaults to all findings.",
),
expectDigest: optionValue("--expect-digest")
.optional()
.describe("Require the payload digest from a reviewed dry run."),
dryRun: z
.boolean()
.default(false)
Expand Down Expand Up @@ -1818,6 +1830,12 @@ export async function main(
dryRun: options.dryRun,
...(linearApiKey === undefined ? {} : { linearApiKey }),
...(assigneeId === undefined ? {} : { assigneeId }),
...(options.finding === undefined
? {}
: { findingIds: options.finding }),
...(options.expectDigest === undefined
? {}
: { expectedDigest: options.expectDigest }),
...(options.dryRun
? {}
: {
Expand Down
11 changes: 7 additions & 4 deletions sdk/typescript/src/publication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ export async function prepareScanPublication(
const contract = await loadContract(scanDirectory, {
pluginRoot: await bundledPluginRoot(),
});
const uploadedAt = options.uploadedAt ?? new Date().toISOString();
const scanId = contract.manifest.scan.id;

return {
Expand All @@ -73,7 +72,11 @@ export async function prepareScanPublication(
findingId: finding.findingId,
occurrenceId: finding.occurrenceId,
title: `[Codex Security][${finding.severity.level.toUpperCase()}] ${finding.title}`,
description: renderFindingDescription(contract, finding, uploadedAt),
description: renderFindingDescription(
contract,
finding,
options.uploadedAt,
),
...(priority === undefined ? {} : { priority }),
};
}),
Expand All @@ -83,7 +86,7 @@ export async function prepareScanPublication(
function renderFindingDescription(
contract: LoadedContract,
finding: Finding,
uploadedAt: string,
uploadedAt: string | undefined,
): string {
const { coverage } = contract;
const { scan } = contract.manifest;
Expand Down Expand Up @@ -116,7 +119,7 @@ function renderFindingDescription(
`**Scan mode:** ${scanMode(coverage.mode)}`,
`**Started:** ${scan.startedAt}`,
`**Completed:** ${scan.completedAt}`,
`**Uploaded:** ${uploadedAt}`,
...(uploadedAt === undefined ? [] : [`**Uploaded:** ${uploadedAt}`]),
"",
"### Affected locations",
"",
Expand Down
Loading
Loading