Skip to content
Draft
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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,32 @@ the optional `CODEX_SECURITY_LINEAR_PROJECT` instead of passing the destination
flags. Add `--dry-run` to preview the issues or `--json` to return
machine-readable results.

By default, publication preserves the existing severity mapping: Critical,
High, Medium, and Low findings become Urgent, High, Medium, and Low Linear
priorities, respectively. To replace that mapping and apply your organization's
own publication rules, pass one or more Markdown, text, PDF, or DOCX policy
documents with repeatable `--knowledge-base` flags:

```bash
export CODEX_SECURITY_LINEAR_API_KEY=YOUR_LINEAR_PERSONAL_API_KEY
npx @openai/codex-security publish scan /path/to/scan \
--to linear \
--linear-team TEAM_ID \
--knowledge-base ./linear-publication-policy.md
```

For example, a company-authored policy can say that P0 findings use Linear's
Urgent priority and internet-facing findings receive an existing `Internet
exposed` label. In knowledge-based publication, that mapping is policy content,
not built-in CLI behavior. When no explicit policy rule matches, priority and
labels are left unset rather than falling back to the default severity mapping.
Knowledge-based publication can set only native Linear priority and existing
labels in the selected team; it cannot create labels or change routing,
content, assignee, state, cycle, estimate, or due date. It requires both normal
Codex authentication and a Linear API key. A knowledge-based `--dry-run`
performs read-only destination and label validation, runs the policy
enrichment, and returns the exact metadata that would be uploaded.

By default, publishing uses your existing Codex sign-in and connected Linear
app without a separate Linear token. To publish directly through the Linear API
instead, set `CODEX_SECURITY_LINEAR_API_KEY` to a Linear personal API key.
Expand Down
54 changes: 52 additions & 2 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -610,8 +610,52 @@ You can also pass `--linear-api-key KEY`, which takes precedence over
`CODEX_SECURITY_LINEAR_API_KEY`. Prefer the environment variable to avoid
exposing your API key in shell history and process listings. API keys are not
added to successful publication results, scan history, or sealed scan artifacts.
Error messages are preserved as returned. `--dry-run` never contacts Linear in
either mode.
Publication errors redact the selected API key. Without a publication knowledge
base, `--dry-run` never contacts Linear in either mode.

Without a publication knowledge base, publication preserves its existing
severity mapping: Critical, High, Medium, and Low findings become Urgent, High,
Medium, and Low Linear priorities, respectively; Informational findings leave
priority unset. Use repeatable `--knowledge-base PATH` options to replace that
mapping with organization-defined publication policy from Markdown, text, PDF,
or DOCX documents:

```bash
cat > linear-publication-policy.md <<'EOF'
# Linear publication policy

- Findings explicitly classified as P0 use the Urgent priority.
- P1 uses High, P2 uses Medium, and P3 uses Low.
- Internet-facing findings receive the existing `Internet exposed` label.
EOF

export CODEX_SECURITY_LINEAR_API_KEY=YOUR_LINEAR_PERSONAL_API_KEY
npx @openai/codex-security publish scan /path/to/completed-scan \
--to linear \
--linear-team TEAM_ID \
--knowledge-base ./linear-publication-policy.md
```

These mappings are only synthetic examples; when a knowledge base is supplied,
the CLI applies only the rules written in your documents. If no explicit rule
matches a finding, its priority and labels remain unset rather than falling back
to the default severity mapping. Knowledge-based publication starts one
ephemeral, read-only Codex turn using your normal Codex authentication. The
turn ignores user configuration and exec rules, disables built-in request
tools, configured external integrations, network access, and search, and does
not persist a Codex session. The Linear API key is used separately to validate
the exact team and project, read the team's label catalog, and create issues;
it is not supplied to the Codex turn. Labels
named by policy must already exist in the selected team. V1 policy output can
set only native priority and existing labels, never routing, title,
description, assignee, state, cycle, estimate, or due date.

`--knowledge-base` requires a Linear API key, so connected-app-only publication
rejects it. `--dry-run --knowledge-base` makes only read-only Linear requests,
runs the same enrichment and validation as publication, and returns the exact
resolved fields in `issues` plus a minimal `appliedMetadata` array. Policy,
paths, prompts, and credentials are never stored in the sealed scan, Codex
session state, or private publication receipt.

Each finding creates a separate new issue titled
`[Codex Security][HIGH] Finding title`. The issue includes the scan ID,
Expand Down Expand Up @@ -666,6 +710,12 @@ const directPublication = await publishScan("/path/to/completed-scan", {
});
```

Add `knowledgeBasePaths: ["./linear-publication-policy.md"]` to direct
publication to resolve priority and existing team labels before creating any
issues. This also requires normal Codex authentication. Inspect
`directPublication.appliedMetadata` to see the numeric Linear priority and
resolved label IDs and names applied to each finding.

### Scan history and reruns

`scans` or `scans list` lists scans for the current repository. Pass a repository
Expand Down
1 change: 1 addition & 0 deletions sdk/typescript/scripts/check-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ const distFiles = new Set(
"models",
"multiscan",
"publication",
"publication-enrichment",
"publication-events",
"publication-store",
"publish",
Expand Down
7 changes: 7 additions & 0 deletions sdk/typescript/scripts/smoke-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,12 @@ try {
const help = runInstalledCli("--help");
assert.match(help, /Usage: codex-security\b/u);
assert.match(help, /\bpublish\b/u);
const publicationHelp = run(
process.execPath,
[launcher, "publish", "scan", "--help"],
{ cwd: consumer, capture: true },
);
assert.match(publicationHelp, /--knowledge-base\b/u);

const publicationScan = join(consumer, "publication-scan");
await cp(
Expand Down Expand Up @@ -445,6 +451,7 @@ try {
assert.equal(publication.counts.findings, 1);
assert.equal(publication.counts.created, 0);
assert.match(publication.issues[0].title, /^\[Codex Security\]\[HIGH\] /u);
assert.equal(publication.issues[0].priority, 2);

const networkGuard = join(consumer, "reject-publication-network.cjs");
await writeFile(
Expand Down
40 changes: 36 additions & 4 deletions sdk/typescript/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ import type { SeverityLevel } from "./models.js";
import {
importLinearIssues,
resolveLinearApiKey,
safeLinearErrorMessage,
type ImportedIssue,
type LinearClientFactory,
} from "./linear.js";
Expand Down Expand Up @@ -423,6 +424,17 @@ class PublicationProgressPresenter {
}

public observe(event: PublishScanProgress): void {
if (event.type === "enrichment_started") {
if (this.#dashboard !== null) {
this.#dashboard.setStage("Applying publication knowledge base");
} else {
this.#write("Applying publication knowledge base.");
}
return;
}

if (event.type === "enrichment_completed") return;

if (event.type === "started") {
if (this.#dashboard !== null) {
this.#dashboard.setPublicationProgress(0, event.total);
Expand Down Expand Up @@ -1595,6 +1607,12 @@ export async function main(
.describe(
"Linear assignee email or user ID; omit to leave issues unassigned.",
),
knowledgeBase: z
.array(optionValue("--knowledge-base"))
.default([])
.describe(
"Apply publication policy files; repeat for multiple paths (requires a Linear API key).",
),
dryRun: z
.boolean()
.default(false)
Expand All @@ -1611,11 +1629,13 @@ export async function main(
const onInterrupt = (): void => cancel("SIGINT");
const onTerminate = (): void => cancel("SIGTERM");
let observingSignals = false;
let publicationLinearApiKey: string | undefined;
try {
const linearApiKey = resolveLinearApiKey(
dependencies.environment,
options.linearApiKey,
);
publicationLinearApiKey = linearApiKey;
const assigneeId = options.linearAssignee?.trim();
if (options.linearAssignee !== undefined && !assigneeId) {
throw new CodexSecurityError("--linear-assignee must not be empty.");
Expand All @@ -1625,6 +1645,11 @@ export async function main(
"--linear-assignee requires --linear-api-key or CODEX_SECURITY_LINEAR_API_KEY.",
);
}
if (options.knowledgeBase.length > 0 && linearApiKey === undefined) {
throw new CodexSecurityError(
"--knowledge-base requires --linear-api-key or CODEX_SECURITY_LINEAR_API_KEY for publication.",
);
}
const teamId =
options.linearTeam?.trim() ||
dependencies.environment["CODEX_SECURITY_LINEAR_TEAM"]?.trim();
Expand Down Expand Up @@ -1820,7 +1845,9 @@ export async function main(
publicationRepository,
);
presentation = progress;
if (!options.dryRun) {
const observesProgress =
!options.dryRun || options.knowledgeBase.length > 0;
if (observesProgress) {
dependencies.addSignalListener("SIGINT", onInterrupt);
dependencies.addSignalListener("SIGTERM", onTerminate);
observingSignals = true;
Expand All @@ -1837,7 +1864,10 @@ export async function main(
dryRun: options.dryRun,
...(linearApiKey === undefined ? {} : { linearApiKey }),
...(assigneeId === undefined ? {} : { assigneeId }),
...(options.dryRun
...(options.knowledgeBase.length === 0
? {}
: { knowledgeBasePaths: options.knowledgeBase }),
...(!observesProgress
? {}
: {
signal: controller.signal,
Expand Down Expand Up @@ -1883,11 +1913,13 @@ export async function main(
const recovery =
error === signal
? ""
: ` ${diagnosticValue(safeErrorMessage(error))}`;
: ` ${diagnosticValue(safeLinearErrorMessage(error, publicationLinearApiKey))}`;
errorOutput.write(`codex-security: ${reason}${recovery}\n`);
exitCode = signal === "SIGINT" ? 130 : 143;
} else {
errorOutput.write(`codex-security: ${errorMessage(error)}\n`);
errorOutput.write(
`codex-security: ${safeLinearErrorMessage(error, publicationLinearApiKey)}\n`,
);
exitCode = 2;
}
return undefined;
Expand Down
5 changes: 5 additions & 0 deletions sdk/typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ export type {
PublishScanProgress,
PublishScanResult,
} from "./publish.js";
export type {
AppliedPublicationMetadata,
LinearPublicationLabel,
PreparedPublicationIssue,
} from "./publication.js";
export { ScanResult } from "./result.js";
export type {
RepositoryFinding,
Expand Down
107 changes: 105 additions & 2 deletions sdk/typescript/src/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import {
} from "@linear/sdk";
import type { JsonObject } from "./config.js";
import { CodexSecurityError, safeErrorMessage } from "./errors.js";
import type { LinearPublicationLabel } from "./publication.js";

export interface LinearPublicationCatalogLabel extends LinearPublicationLabel {
groupId?: string;
groupName?: string;
}

export type LinearClientFactory<
Method extends keyof LinearClient = "issue" | "projects",
Expand All @@ -32,6 +38,88 @@ export function createLinearClient<Method extends keyof LinearClient>(
return factory ? factory(configuration) : new LinearClient(configuration);
}

export interface LinearPublicationContext {
labels: LinearPublicationCatalogLabel[];
}

export async function loadLinearPublicationContext(
client: Pick<LinearClient, "team" | "project" | "issueLabels">,
teamId: string,
projectId?: string,
): Promise<LinearPublicationContext> {
const team = await client.team(teamId);
if (team === undefined || team.id !== teamId) {
throw new CodexSecurityError(
"The selected Linear team was not found or is not accessible.",
);
}

if (projectId !== undefined) {
const project = await client.project(projectId);
if (project === undefined || project.id !== projectId) {
throw new CodexSecurityError(
"The selected Linear project was not found or is not accessible.",
);
}
const teams = await project.teams({ first: 50 });
while (teams.pageInfo.hasNextPage) await teams.fetchNext();
if (!teams.nodes.some(({ id }) => id === teamId)) {
throw new CodexSecurityError(
"The selected Linear project does not belong to the selected team.",
);
}
}

const page = await team.labels({ first: 50 });
while (page.pageInfo.hasNextPage) await page.fetchNext();
const workspacePage = await client.issueLabels({
first: 50,
filter: { team: { null: true } },
});
while (workspacePage.pageInfo.hasNextPage) {
await workspacePage.fetchNext();
}
const applicableLabels = [
...page.nodes,
...workspacePage.nodes.filter(({ teamId }) => teamId === undefined),
];
const labels = new Map<string, LinearPublicationCatalogLabel>();
const groupNames = new Map(
applicableLabels
.filter(
(label) =>
label.isGroup &&
label.archivedAt === undefined &&
label.retiredById === undefined,
)
.map((label) => [label.id, label.name]),
);
for (const label of applicableLabels) {
if (
label.isGroup ||
label.archivedAt !== undefined ||
label.retiredById !== undefined
) {
continue;
}
labels.set(label.id, {
id: label.id,
name: label.name,
...(label.parentId === undefined ? {} : { groupId: label.parentId }),
...(label.parentId === undefined ||
groupNames.get(label.parentId) === undefined
? {}
: { groupName: groupNames.get(label.parentId)! }),
});
}
return {
labels: [...labels.values()].sort(
(left, right) =>
left.name.localeCompare(right.name) || left.id.localeCompare(right.id),
),
};
}

export interface ImportedIssue {
source: "linear";
id: string;
Expand Down Expand Up @@ -131,13 +219,28 @@ export async function importLinearIssues(options: {
"Linear request was rate limited. Wait and retry.",
);
}
const message = safeErrorMessage(error);
throw new CodexSecurityError(
`Linear request failed: ${message.includes(credential) ? "[redacted]" : message}`,
`Linear request failed: ${safeLinearErrorMessage(error, credential)}`,
);
}
}

export function safeLinearErrorMessage(
error: unknown,
credential: string | undefined,
): string {
return redactLinearCredential(safeErrorMessage(error), credential);
}

export function redactLinearCredential(
message: string,
credential: string | undefined,
): string {
return credential === undefined || !message.includes(credential)
? message
: message.replaceAll(credential, "[redacted]");
}

function linearIssueFilter(input: string | undefined): JsonObject {
if (input === undefined) return {};
let filter: unknown;
Expand Down
Loading
Loading