diff --git a/README.md b/README.md index cc7a02b9..85a2f27b 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,11 @@ directory outside the repository. `findings list [repository]` shows open findings across a repository's scans and identifies findings not confirmed in its latest scan. +Use `patch --linear-issue SEC-123` to import and fix a Linear issue, or +`patch --linear-project "Security backlog" --linear-filter '{"labels":{"name":{"eq":"security"}}}'` +to fix matching open issues from a project. Set +`CODEX_SECURITY_LINEAR_API_KEY` to authorize read-only Linear access. + `scans compare BEFORE_SCAN_ID AFTER_SCAN_ID` automatically matches findings by root cause, reuses saved matches, and identifies new, persisting, reopened, resolved, or unknown findings. Missing findings remain unknown when coverage is @@ -89,8 +94,9 @@ npx @openai/codex-security publish scan /path/to/scan \ --linear-team TEAM_ID ``` -Add `--project PROJECT_ID` to place the issues in a Linear project, or omit it -to create issues directly in the team. Omit the scan directory to select a +Add `--linear-project PROJECT_ID` to place the issues in a Linear project, or +omit it to create issues directly in the team. The existing `--project` flag +remains an alias. Omit the scan directory to select a completed scan interactively. You can also set `CODEX_SECURITY_LINEAR_TEAM` and the optional `CODEX_SECURITY_LINEAR_PROJECT` instead of passing the destination flags. Add `--dry-run` to preview the issues or `--json` to return @@ -107,7 +113,7 @@ 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 \ - --project PROJECT_ID \ + --linear-project PROJECT_ID \ --linear-assignee teammate@example.com ``` diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 1c6b4bf6..44d5fd7d 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -248,6 +248,8 @@ npx @openai/codex-security validate /path/outside/repository/findings.json "Poss npx @openai/codex-security validate "Possible SQL injection" --effort high npx @openai/codex-security patch /path/outside/repository/findings.json "Missing authorization check in src/routes.ts:18" npx @openai/codex-security patch "Missing authorization check" --effort high +npx @openai/codex-security patch --linear-issue SEC-123 --linear-issue SEC-124 +npx @openai/codex-security patch --linear-project "Security backlog" --linear-filter '{"labels":{"name":{"eq":"security"}}}' ``` Run `npx @openai/codex-security --version` for the installed CLI version or @@ -435,7 +437,7 @@ The CLI and SDK recognize the following user-configurable environment: | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | `OPENAI_API_KEY`, `CODEX_API_KEY` | Scan authentication; `OPENAI_API_KEY` wins when both are present. | | `CODEX_SECURITY_LINEAR_TEAM`, `CODEX_SECURITY_LINEAR_PROJECT` | Default Linear team and project for completed-scan publication. | -| `CODEX_SECURITY_LINEAR_API_KEY` | Publish directly to Linear with a personal API key; safer than a command-line key. | +| `CODEX_SECURITY_LINEAR_API_KEY` | Patch Linear issues or publish directly with a personal API key. | | `CODEX_SECURITY_LOG_LEVEL` | CLI-only; set to `debug` for verbose diagnostics. | | `LOG_LEVEL` | CLI-only fallback when `CODEX_SECURITY_LOG_LEVEL` is unset. | | `CODEX_SECURITY_STATE_DIR` | Override the private scan-history, workbench, and default artifact directory. | @@ -559,8 +561,9 @@ npx @openai/codex-security publish scan /path/to/completed-scan \ --linear-team TEAM_ID ``` -Add `--project PROJECT_ID` to place the issues in a Linear project. Without a -project, issues are created directly in the selected team. +Add `--linear-project PROJECT_ID` to place the issues in a Linear project. +The existing `--project` flag remains an alias. Without a project, issues are +created directly in the selected team. To choose from all completed scans saved in your local scan history, omit the scan directory. The selector highlights each repository and shows its finding @@ -757,6 +760,19 @@ print the final response without the underlying Codex event stream. Override the model with `--codex 'model="gpt-5.6-sol"'` and the reasoning effort with `--effort high` or `--codex 'model_reasoning_effort="high"'`. +Use `patch --linear-issue ISSUE` to import a Linear issue by identifier or URL. +Repeat `--linear-issue` to include more issues. Use +`patch --linear-project "PROJECT"` to patch every open issue in a project. Add +`--linear-filter '{"labels":{"name":{"eq":"security"}}}'` to apply a native +Linear issue filter on the server. Completed and canceled issues are excluded +unless the filter explicitly sets `state`. Set `CODEX_SECURITY_LINEAR_API_KEY` +for a personal API key, or `LINEAR_ACCESS_TOKEN` for an OAuth access token. +`LINEAR_API_KEY` is also accepted. `--linear-api-key KEY` overrides these +environment settings; prefer the environment variable to keep keys out of shell +history. Imported content is always literal, and issue URLs must match the +selected workspace. Linear access is read-only, and its credentials are not +passed to the patch subprocess. + Exit codes are `0` for a completed report-only scan or a passing policy, `1` for a completed policy violation, `2` for invalid input, incomplete coverage, or a runtime/export error, `130` for interruption, and `143` for termination. diff --git a/sdk/typescript/scripts/check-package.mjs b/sdk/typescript/scripts/check-package.mjs index 1d5f0ffd..9cd6feb8 100644 --- a/sdk/typescript/scripts/check-package.mjs +++ b/sdk/typescript/scripts/check-package.mjs @@ -170,6 +170,7 @@ const distFiles = new Set( "errors", "index", "knowledge-base", + "linear", "models", "multiscan", "publication", diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 57e06a7e..7ec2fe7c 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -82,6 +82,12 @@ import { ScanInterruptedError, } from "./errors.js"; import type { SeverityLevel } from "./models.js"; +import { + importLinearIssues, + resolveLinearApiKey, + type ImportedIssue, + type LinearClientFactory, +} from "./linear.js"; import { runMultiscan } from "./multiscan.js"; import { publishScan, @@ -204,6 +210,9 @@ const VALUE_OPTIONS = new Set([ "--plugin-path", "--python", "--codex", + "--linear-issue", + "--linear-project", + "--linear-filter", "--fail-on-severity", "--max-cost", "--workers", @@ -236,6 +245,17 @@ function optionValue(flag: string) { return z.string().min(1, `${flag} must not be empty.`); } +function linearApiKeyOption() { + return z + .string() + .trim() + .min(1, "--linear-api-key must not be empty.") + .optional() + .describe( + "Linear personal API key; defaults to CODEX_SECURITY_LINEAR_API_KEY.", + ); +} + function publicationScanAge(timestamp: string, now: number): string { const completedAt = Date.parse(timestamp); if (!Number.isFinite(completedAt)) return "unknown"; @@ -715,6 +735,7 @@ interface CliDependencies { environment?: NodeJS.ProcessEnv, ): Promise; bulkScan?: BulkScanDiscoveryDependencies; + linearClient?: LinearClientFactory; runWorkbench(args: readonly string[]): Promise; matchFindings: typeof matchScanFindings; checkForUpdate(signal: AbortSignal): Promise; @@ -1540,16 +1561,16 @@ export async function main( linearTeam: optionValue("--linear-team") .optional() .describe("Linear team ID; defaults to CODEX_SECURITY_LINEAR_TEAM."), - linearApiKey: optionValue("--linear-api-key") + linearApiKey: linearApiKeyOption(), + linearProject: optionValue("--linear-project") .optional() .describe( - "Linear personal API key; defaults to CODEX_SECURITY_LINEAR_API_KEY.", + "Optional Linear project ID; defaults to CODEX_SECURITY_LINEAR_PROJECT.", ), project: optionValue("--project") .optional() - .describe( - "Optional Linear project ID; defaults to CODEX_SECURITY_LINEAR_PROJECT.", - ), + .describe("Alias for --linear-project.") + .meta({ deprecated: true }), linearAssignee: optionValue("--linear-assignee") .optional() .describe( @@ -1572,13 +1593,10 @@ export async function main( const onTerminate = (): void => cancel("SIGTERM"); let observingSignals = false; try { - const selectedApiKey = - options.linearApiKey ?? - dependencies.environment["CODEX_SECURITY_LINEAR_API_KEY"]; - const linearApiKey = selectedApiKey?.trim() || undefined; - if (options.linearApiKey !== undefined && linearApiKey === undefined) { - throw new CodexSecurityError("--linear-api-key must not be empty."); - } + const linearApiKey = resolveLinearApiKey( + dependencies.environment, + options.linearApiKey, + ); const assigneeId = options.linearAssignee?.trim(); if (options.linearAssignee !== undefined && !assigneeId) { throw new CodexSecurityError("--linear-assignee must not be empty."); @@ -1596,9 +1614,21 @@ export async function main( "--linear-team or CODEX_SECURITY_LINEAR_TEAM is required.", ); } - const selectedProject = options.project?.trim(); - if (options.project !== undefined && !selectedProject) { - throw new CodexSecurityError("--project must not be empty."); + if ( + options.linearProject !== undefined && + options.project !== undefined && + options.linearProject.trim() !== options.project.trim() + ) { + throw new CodexSecurityError( + "--linear-project and --project must select the same project.", + ); + } + const projectOption = options.linearProject ?? options.project; + const selectedProject = projectOption?.trim(); + if (projectOption !== undefined && !selectedProject) { + throw new CodexSecurityError( + `${options.linearProject === undefined ? "--project" : "--linear-project"} must not be empty.`, + ); } const projectId = selectedProject || @@ -2450,10 +2480,22 @@ export async function main( "issues...": z .string() .min(1, "An issue must not be empty.") + .optional() .describe("Issue text or a file containing issues."), }), options: z.object({ effort: effortOption(), + linearIssue: z + .array(optionValue("--linear-issue")) + .default([]) + .describe("Linear issue identifier or URL; repeat for more issues."), + linearProject: optionValue("--linear-project") + .optional() + .describe("Patch every open issue in this Linear project."), + linearFilter: optionValue("--linear-filter") + .optional() + .describe("JSON Linear issue filter for --linear-project."), + linearApiKey: linearApiKeyOption(), codex: z .array(optionValue("--codex")) .default([]) @@ -2463,14 +2505,59 @@ export async function main( }), async run({ options }) { try { + const linear = + options.linearIssue.length > 0 || !!options.linearProject; + if (options.linearIssue.length > 0 && options.linearProject) { + throw new CodexSecurityError( + "Use either --linear-issue or --linear-project, not both.", + ); + } + if (options.linearFilter && !options.linearProject) { + throw new CodexSecurityError( + "--linear-filter requires --linear-project.", + ); + } + if (options.linearApiKey !== undefined && !linear) { + throw new CodexSecurityError( + "--linear-api-key requires --linear-issue or --linear-project.", + ); + } + if (positionals.length === 0 && !linear) { + throw new CodexSecurityError( + "Patch requires an issue, --linear-issue, or --linear-project.", + ); + } + + const imports = linear + ? await importLinearIssues({ + issues: options.linearIssue, + project: options.linearProject, + filter: options.linearFilter, + apiKey: options.linearApiKey, + environment: dependencies.environment, + linearClient: dependencies.linearClient, + }) + : []; + const environment = + imports.length === 0 + ? undefined + : Object.fromEntries( + Object.entries(dependencies.environment).filter( + ([name]) => + !/^(?:CODEX_SECURITY_)?LINEAR_(?:API_KEY|ACCESS_TOKEN)$/iu.test( + name, + ), + ), + ); exitCode = await runSkill( "fix-finding", - positionals, + [...positionals, ...imports], options.codex, options.effort, output, errorOutput, dependencies, + environment, ); } catch (error) { exitCode = 2; @@ -3101,12 +3188,13 @@ function staysWithinWindowsDeviceRoot(input: string, root: string): boolean { async function runSkill( skill: "validation" | "fix-finding", - inputs: readonly string[], + inputs: readonly (string | ImportedIssue)[], codexOverrides: readonly string[], effort: ScanReasoningEffort | undefined, stdout: Writable, stderr: Writable, dependencies: CliDependencies, + environment?: NodeJS.ProcessEnv, ): Promise { const overrides = parseCodexOverrides(codexOverrides, undefined, effort); if ( @@ -3124,6 +3212,12 @@ async function runSkill( const directory = dependencies.currentDirectory(); const contents: string[] = []; for (const input of inputs) { + if (typeof input !== "string") { + contents.push( + `Source: ${input.source}\nIssue: ${input.id}\nURL: ${input.url}\n\n${input.text}`, + ); + continue; + } if (input.trim().length === 0) { throw new CodexSecurityError( "Finding or issue inputs must not be empty.", @@ -3226,6 +3320,7 @@ async function runSkill( stdout, stderr, }, + environment, ); } diff --git a/sdk/typescript/src/linear.ts b/sdk/typescript/src/linear.ts new file mode 100644 index 00000000..87d3decb --- /dev/null +++ b/sdk/typescript/src/linear.ts @@ -0,0 +1,170 @@ +import { + AuthenticationLinearError, + ForbiddenLinearError, + LinearClient, + RatelimitedLinearError, +} from "@linear/sdk"; +import type { JsonObject } from "./config.js"; +import { CodexSecurityError, safeErrorMessage } from "./errors.js"; + +export type LinearClientFactory< + Method extends keyof LinearClient = "issue" | "projects", +> = ( + options: ConstructorParameters[0], +) => Pick; + +export function resolveLinearApiKey( + environment: NodeJS.ProcessEnv, + explicit?: string, +): string | undefined { + return ( + explicit?.trim() || + environment["CODEX_SECURITY_LINEAR_API_KEY"]?.trim() || + undefined + ); +} + +export function createLinearClient( + options: ConstructorParameters[0], + factory?: LinearClientFactory, +): Pick { + const configuration = { ...options, redirect: "error" as const }; + return factory ? factory(configuration) : new LinearClient(configuration); +} + +export interface ImportedIssue { + source: "linear"; + id: string; + url: string; + text: string; +} + +export async function importLinearIssues(options: { + issues: readonly string[]; + project?: string; + filter?: string; + apiKey?: string; + environment: NodeJS.ProcessEnv; + linearClient?: LinearClientFactory; +}): Promise { + const apiKey = + resolveLinearApiKey(options.environment, options.apiKey) || + options.environment["LINEAR_API_KEY"]?.trim(); + const accessToken = options.environment["LINEAR_ACCESS_TOKEN"]?.trim(); + const credential = apiKey || accessToken; + if (!credential) { + throw new CodexSecurityError( + "Linear access requires CODEX_SECURITY_LINEAR_API_KEY, LINEAR_API_KEY, or LINEAR_ACCESS_TOKEN.", + ); + } + + const client = createLinearClient( + apiKey ? { apiKey } : { accessToken }, + options.linearClient, + ); + + try { + const issues: Awaited>[] = []; + if (options.project !== undefined) { + const suppliedFilter = linearIssueFilter(options.filter); + const filter = Object.hasOwn(suppliedFilter, "state") + ? suppliedFilter + : { + state: { type: { nin: ["completed", "canceled"] } }, + ...suppliedFilter, + }; + const projects = await client.projects({ + filter: { name: { eqIgnoreCase: options.project } }, + first: 2, + }); + if (projects.nodes.length !== 1) { + throw new CodexSecurityError( + `Linear project "${options.project}" ${projects.nodes.length === 0 ? "was not found or is not accessible" : "is ambiguous"}.`, + ); + } + + const page = await projects.nodes[0]!.issues({ first: 50, filter }); + while (page.pageInfo.hasNextPage) await page.fetchNext(); + issues.push(...page.nodes); + if (issues.length === 0) { + throw new CodexSecurityError( + `No open Linear issues matched project "${options.project}" and its filter.`, + ); + } + } else { + for (const input of options.issues) { + const { id, workspace } = linearIssueReference(input); + const issue = await client.issue(id); + if (!issue) { + throw new CodexSecurityError( + `Linear issue "${id}" was not found or is not accessible.`, + ); + } + if ( + workspace !== undefined && + linearIssueReference(issue.url).workspace !== workspace + ) { + throw new CodexSecurityError( + "Fetched Linear issue does not match the workspace in the selected URL.", + ); + } + issues.push(issue); + } + } + + return issues.map(({ identifier, title, url, description }) => ({ + source: "linear", + id: identifier, + url, + text: `Title: ${title}\n\n${description ?? ""}`, + })); + } catch (error) { + if (error instanceof CodexSecurityError) throw error; + if ( + error instanceof AuthenticationLinearError || + error instanceof ForbiddenLinearError + ) { + throw new CodexSecurityError("Linear authentication failed."); + } + if (error instanceof RatelimitedLinearError) { + throw new CodexSecurityError( + "Linear request was rate limited. Wait and retry.", + ); + } + const message = safeErrorMessage(error); + throw new CodexSecurityError( + `Linear request failed: ${message.includes(credential) ? "[redacted]" : message}`, + ); + } +} + +function linearIssueFilter(input: string | undefined): JsonObject { + if (input === undefined) return {}; + let filter: unknown; + try { + filter = JSON.parse(input); + } catch { + filter = null; + } + if (typeof filter === "object" && filter !== null && !Array.isArray(filter)) { + return filter as JsonObject; + } + throw new CodexSecurityError( + "--linear-filter must be a JSON Linear issue filter.", + ); +} + +function linearIssueReference(input: string): { + id: string; + workspace?: string; +} { + if (!/^https?:\/\//iu.test(input)) return { id: input }; + const url = new URL(input); + const match = /^\/([^/]+)\/issue\/([A-Z][A-Z0-9]*-\d+)(?:\/|$)/iu.exec( + url.pathname, + ); + if (url.hostname !== "linear.app" || match === null) { + throw new CodexSecurityError("Linear issue URL is invalid."); + } + return { id: match[2]!, workspace: match[1]!.toLowerCase() }; +} diff --git a/sdk/typescript/src/publish.ts b/sdk/typescript/src/publish.ts index 266de10f..93639959 100644 --- a/sdk/typescript/src/publish.ts +++ b/sdk/typescript/src/publish.ts @@ -9,12 +9,17 @@ import { writeFile, } from "node:fs/promises"; import { join } from "node:path"; -import { LinearClient } from "@linear/sdk"; +import type { LinearClient } from "@linear/sdk"; import { CodexSecurityError, ConfigurationError, safeErrorMessage, } from "./errors.js"; +import { + createLinearClient, + resolveLinearApiKey, + type LinearClientFactory, +} from "./linear.js"; import { prepareScanPublication, type LinearPublicationDestination, @@ -95,9 +100,7 @@ export interface PublicationCodexResult { export interface PublishScanDependencies { environment?: NodeJS.ProcessEnv; - linearClient?: ( - options: ConstructorParameters[0], - ) => Pick; + linearClient?: LinearClientFactory<"users" | "createIssue">; prepare?: typeof prepareScanPublication; resolveCodex?: (environment: NodeJS.ProcessEnv) => CodexCommand; runCodex?: ( @@ -142,10 +145,7 @@ export async function publishScanInternal( } const environment = dependencies.environment ?? process.env; - const linearApiKey = - options.linearApiKey?.trim() || - environment["CODEX_SECURITY_LINEAR_API_KEY"]?.trim() || - undefined; + const linearApiKey = resolveLinearApiKey(environment, options.linearApiKey); if (options.assigneeId !== undefined && linearApiKey === undefined) { throw new ConfigurationError( "A Linear API key is required to select a publication assignee.", @@ -182,14 +182,13 @@ export async function publishScanInternal( const linearClient = linearApiKey === undefined ? undefined - : ( - dependencies.linearClient ?? - ((configuration) => new LinearClient(configuration)) - )({ - apiKey: linearApiKey, - redirect: "error", - ...(options.signal === undefined ? {} : { signal: options.signal }), - }); + : createLinearClient( + { + apiKey: linearApiKey, + ...(options.signal === undefined ? {} : { signal: options.signal }), + }, + dependencies.linearClient, + ); let assigneeId = options.assigneeId; if (linearClient !== undefined && assigneeId?.includes("@")) { const users = await linearClient.users({ diff --git a/sdk/typescript/tests-ts/cli-fixtures.ts b/sdk/typescript/tests-ts/cli-fixtures.ts index 01b839bc..597155d0 100644 --- a/sdk/typescript/tests-ts/cli-fixtures.ts +++ b/sdk/typescript/tests-ts/cli-fixtures.ts @@ -190,7 +190,11 @@ export function dependencies( onRun?: () => void; onInterrupt?: () => void; onClose?: () => void | Promise; - onCodex?: (args: readonly string[]) => number; + onCodex?: ( + args: readonly string[], + environment?: NodeJS.ProcessEnv, + ) => number; + linearClient?: MainDependencies["linearClient"]; bulkScan?: MainDependencies["bulkScan"]; onWorkbench?: (args: readonly string[]) => JsonObject | Promise; onMatch?: MainDependencies["matchFindings"]; @@ -253,8 +257,12 @@ export function dependencies( signals.remove(signal, listener), writeSynchronously: (stream, value) => stream.write(value), forceExit: () => {}, - runCodex: async (args) => options.onCodex?.(args) ?? 0, + runCodex: async (args, _output, environment) => + options.onCodex?.(args, environment) ?? 0, ...(options.bulkScan === undefined ? {} : { bulkScan: options.bulkScan }), + ...(options.linearClient === undefined + ? {} + : { linearClient: options.linearClient }), runWorkbench: async (args) => (await options.onWorkbench?.(args)) ?? { scans: [] }, matchFindings: async (input) => diff --git a/sdk/typescript/tests-ts/cli-publish.test.ts b/sdk/typescript/tests-ts/cli-publish.test.ts index 743daf6f..8d5ec513 100644 --- a/sdk/typescript/tests-ts/cli-publish.test.ts +++ b/sdk/typescript/tests-ts/cli-publish.test.ts @@ -11,7 +11,7 @@ const DESTINATION_OPTIONS = [ "linear", "--linear-team", "team-from-flags", - "--project", + "--linear-project", "project-from-flags", ] as const; const temporaryDirectories: string[] = []; @@ -74,6 +74,57 @@ function publicationResult( } describe("publish scan", () => { + test("accepts the Linear project flag and its published alias", async () => { + for (const flag of ["--linear-project", "--project"]) { + let projectId: string | undefined; + const deps = dependencies(); + deps.publishScan = async (_directory, options) => { + projectId = options.projectId; + return publicationResult(); + }; + expect( + await main( + [ + "publish", + "scan", + "completed-scan", + "--to", + "linear", + "--linear-team", + "team-id", + flag, + "selected-project", + "--json", + ], + capture().stream, + capture().stream, + deps, + ), + ).toBe(0); + expect(projectId).toBe("selected-project"); + } + + const stderr = capture(); + expect( + await main( + [ + "publish", + "scan", + "completed-scan", + ...DESTINATION_OPTIONS, + "--project", + "different-project", + ], + capture().stream, + stderr.stream, + dependencies(), + ), + ).toBe(2); + expect(stderr.text()).toContain( + "--linear-project and --project must select the same project.", + ); + }); + test("publishes an explicit scan directory without inspecting scan history", async () => { const currentDirectory = join(tmpdir(), "codex-security-publish-current"); const stdout = capture(); diff --git a/sdk/typescript/tests-ts/cli-skills.test.ts b/sdk/typescript/tests-ts/cli-skills.test.ts index 288847de..6316f30d 100644 --- a/sdk/typescript/tests-ts/cli-skills.test.ts +++ b/sdk/typescript/tests-ts/cli-skills.test.ts @@ -2,7 +2,7 @@ import { execFileSync, spawn } from "node:child_process"; import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import * as filesystem from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { join, posix, resolve, win32 } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { describe, expect, spyOn, test } from "bun:test"; import { @@ -11,8 +11,18 @@ import { runCodexSkillCommand, skillCommandFailure, } from "../src/cli.js"; +import type { LinearClientFactory } from "../src/linear.js"; import { capture, dependencies } from "./cli-fixtures.js"; +function linearIssue(identifier: string) { + return { + identifier, + title: `Fix ${identifier}`, + description: `Synthetic evidence for ${identifier}`, + url: `https://linear.app/example/issue/${identifier}`, + }; +} + describe("CLI skill commands", () => { test("runs validation and patch skills with file and literal inputs", async () => { const directory = await mkdtemp(join(tmpdir(), "codex-security-skills-")); @@ -93,7 +103,7 @@ describe("CLI skill commands", () => { ), ).toBe(0); expect(help.text()).toContain( - `Usage: codex-security ${command} <${argument}>`, + `Usage: codex-security ${command} ${command === "patch" ? `[${argument}]` : `<${argument}>`}`, ); expect(help.text()).toContain( "--effort ", @@ -108,6 +118,260 @@ describe("CLI skill commands", () => { } }); + test("imports selected Linear issues without exposing its credential to Codex", async () => { + const requests: string[] = []; + let inputs: string[] = []; + let environment: NodeJS.ProcessEnv | undefined; + + expect( + await main( + [ + "patch", + "--linear-issue", + "SEC-123", + "--linear-issue", + "https://linear.app/example/issue/SEC-124/a-synthetic-finding", + "--linear-api-key", + "lin_api_SYNTHETIC_EXPLICIT", + ], + capture().stream, + capture().stream, + dependencies({ + environment: { + CODEX_SECURITY_LINEAR_API_KEY: "lin_api_SYNTHETIC_SECRET", + LINEAR_API_KEY: "lin_api_SYNTHETIC_FALLBACK", + LINEAR_ACCESS_TOKEN: "SYNTHETIC_OAUTH_TOKEN", + OPENAI_API_KEY: "sk-proj-SYNTHETIC_MODEL_KEY", + }, + linearClient: ({ apiKey, redirect }) => { + expect(apiKey).toBe("lin_api_SYNTHETIC_EXPLICIT"); + expect(redirect).toBe("error"); + return { + issue: async (id: string) => { + requests.push(id); + return linearIssue(id); + }, + } as ReturnType; + }, + onCodex: (args, processEnvironment) => { + inputs = JSON.parse(args.at(-1)!.split("\n").at(-1)!); + environment = processEnvironment; + return 0; + }, + }), + ), + ).toBe(0); + + expect(requests).toEqual(["SEC-123", "SEC-124"]); + expect(inputs).toHaveLength(2); + expect(inputs[0]).toContain("Issue: SEC-123"); + expect(inputs[1]).toContain("Synthetic evidence for SEC-124"); + expect(environment).toEqual({ + OPENAI_API_KEY: "sk-proj-SYNTHETIC_MODEL_KEY", + }); + expect(JSON.stringify(inputs)).not.toContain("lin_api_SYNTHETIC_SECRET"); + expect(JSON.stringify(inputs)).not.toContain("lin_api_SYNTHETIC_EXPLICIT"); + }); + + test("keeps imported Unix and Windows paths literal", async () => { + const root = await mkdtemp(join(tmpdir(), "codex-security-linear-input-")); + try { + const repository = join(root, "repository"); + const selected = join(repository, "selected.txt"); + const external = join(root, "external.txt"); + await mkdir(repository); + await writeFile(selected, "selected file contents"); + await writeFile(external, "SYNTHETIC_EXTERNAL_FILE"); + + for (const [paths, target] of [ + [posix, process.platform === "win32" ? "/synthetic.txt" : external], + [win32, process.platform === "win32" ? external : "C:\\synthetic.txt"], + ] as const) { + const issue = { + ...linearIssue("SEC-123"), + description: + paths.sep + + `..${paths.sep}`.repeat(32) + + paths.relative(paths.parse(target).root, target), + }; + const expected = `Source: linear\nIssue: SEC-123\nURL: ${issue.url}\n\nTitle: ${issue.title}\n\n${issue.description}`; + const forbiddenPath = resolve(repository, expected); + const originalLstat = filesystem.lstat; + let probed = false; + let inputs: string[] = []; + const reading = spyOn(filesystem, "lstat").mockImplementation((async ( + ...args: Parameters + ) => { + if (String(args[0]) === forbiddenPath) probed = true; + return await originalLstat(...args); + }) as typeof filesystem.lstat); + try { + expect( + await main( + ["patch", selected, "--linear-issue", "SEC-123"], + capture().stream, + capture().stream, + dependencies({ + currentDirectory: repository, + environment: { CODEX_SECURITY_LINEAR_API_KEY: "synthetic-key" }, + linearClient: () => + ({ + issue: async () => issue, + }) as unknown as ReturnType, + onCodex: (args) => { + inputs = JSON.parse(args.at(-1)!.split("\n").at(-1)!); + return 0; + }, + }), + ), + ).toBe(0); + expect(probed).toBe(false); + expect(inputs).toEqual(["selected file contents", expected]); + expect(inputs).not.toContain("SYNTHETIC_EXTERNAL_FILE"); + } finally { + reading.mockRestore(); + } + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test("imports every matching open project issue across Linear pages", async () => { + let projectOptions: unknown; + let issueOptions: unknown; + let nextPages = 0; + let inputs: string[] = []; + + expect( + await main( + [ + "patch", + "--linear-project", + "Security backlog", + "--linear-filter", + '{"labels":{"name":{"eq":"security"}}}', + ], + capture().stream, + capture().stream, + dependencies({ + environment: { LINEAR_ACCESS_TOKEN: "SYNTHETIC_OAUTH_TOKEN" }, + linearClient: ({ accessToken }) => { + expect(accessToken).toBe("SYNTHETIC_OAUTH_TOKEN"); + const page = { + nodes: [linearIssue("SEC-123")], + pageInfo: { hasNextPage: true }, + async fetchNext() { + nextPages++; + this.nodes.push(linearIssue("SEC-124")); + this.pageInfo.hasNextPage = false; + return this; + }, + }; + return { + projects: async (options: unknown) => { + projectOptions = options; + return { + nodes: [ + { + issues: async (options: unknown) => { + issueOptions = options; + return page; + }, + }, + ], + }; + }, + } as unknown as ReturnType; + }, + onCodex: (args, environment) => { + inputs = JSON.parse(args.at(-1)!.split("\n").at(-1)!); + expect(environment).toEqual({}); + return 0; + }, + }), + ), + ).toBe(0); + + expect(projectOptions).toEqual({ + filter: { name: { eqIgnoreCase: "Security backlog" } }, + first: 2, + }); + expect(issueOptions).toEqual({ + first: 50, + filter: { + state: { type: { nin: ["completed", "canceled"] } }, + labels: { name: { eq: "security" } }, + }, + }); + expect(nextPages).toBe(1); + expect(inputs).toHaveLength(2); + expect(inputs[0]).toContain("Issue: SEC-123"); + expect(inputs[1]).toContain("Issue: SEC-124"); + }); + + test("rejects invalid Linear selections before starting Codex", async () => { + const cases: [string[], string, NodeJS.ProcessEnv?][] = [ + [ + ["patch"], + "Patch requires an issue, --linear-issue, or --linear-project.", + ], + [ + ["patch", "--linear-issue", "SEC-123"], + "Linear access requires CODEX_SECURITY_LINEAR_API_KEY, LINEAR_API_KEY, or LINEAR_ACCESS_TOKEN.", + {}, + ], + [ + ["patch", "--linear-project", "Backlog", "--linear-filter", "invalid"], + "--linear-filter must be a JSON Linear issue filter.", + ], + [ + ["patch", "--linear-issue", "SEC-123", "--linear-filter", "{}"], + "--linear-filter requires --linear-project.", + ], + [ + ["patch", "--linear-issue", "SEC-123", "--linear-project", "Backlog"], + "Use either --linear-issue or --linear-project, not both.", + ], + [ + ["patch", "--linear-issue", "https://example.test/issue/SEC-123"], + "Linear issue URL is invalid.", + ], + [ + ["patch", "ordinary issue", "--linear-api-key", "synthetic-key"], + "--linear-api-key requires --linear-issue or --linear-project.", + ], + [ + ["patch", "--linear-issue", "SEC-123", "--linear-api-key", " "], + "--linear-api-key must not be empty.", + ], + ]; + + for (const [args, message, environment] of cases) { + let started = false; + const stderr = capture(); + expect( + await main( + args, + capture().stream, + stderr.stream, + dependencies({ + environment: environment ?? { + CODEX_SECURITY_LINEAR_API_KEY: "lin_api_SYNTHETIC_SECRET", + }, + onCodex: () => { + started = true; + return 0; + }, + }), + ), + ).toBe(2); + expect(stderr.text()).toContain(message); + expect(stderr.text()).not.toContain("lin_api_SYNTHETIC_SECRET"); + expect(started).toBe(false); + } + }); + test("rejects linked findings while preserving selected external files", async () => { const root = await mkdtemp(join(tmpdir(), "codex-security-skill-inputs-")); try { diff --git a/sdk/typescript/tests-ts/cli.test.ts b/sdk/typescript/tests-ts/cli.test.ts index b8401334..1d923f15 100644 --- a/sdk/typescript/tests-ts/cli.test.ts +++ b/sdk/typescript/tests-ts/cli.test.ts @@ -194,7 +194,7 @@ describe("CLI", () => { expect(manifest.text()).toContain("codex-security bulk-scan [input]"); expect(manifest.text()).toContain("codex-security export [scanDir]"); expect(manifest.text()).toContain("codex-security validate "); - expect(manifest.text()).toContain("codex-security patch "); + expect(manifest.text()).toContain("codex-security patch [issues...]"); expect(manifest.text()).toContain( "codex-security findings false-positive ", ); @@ -2688,7 +2688,10 @@ describe("CLI", () => { [["export", "scan-a", "scan-b"], "Unexpected positional"], [["validate"], "findings..."], [["validate", ""], "A finding must not be empty"], - [["patch"], "issues..."], + [ + ["patch"], + "Patch requires an issue, --linear-issue, or --linear-project.", + ], [["patch", ""], "An issue must not be empty"], [ ["export", "scan", "--output", "--source-root", "repo"], diff --git a/sdk/typescript/tests-ts/linear.test.ts b/sdk/typescript/tests-ts/linear.test.ts new file mode 100644 index 00000000..7db6ab16 --- /dev/null +++ b/sdk/typescript/tests-ts/linear.test.ts @@ -0,0 +1,176 @@ +import { AuthenticationLinearError, RatelimitedLinearError } from "@linear/sdk"; +import { describe, expect, test } from "bun:test"; +import { + createLinearClient, + importLinearIssues, + resolveLinearApiKey, + type LinearClientFactory, +} from "../src/linear.js"; + +type LinearImportClient = ReturnType; + +function projectClient( + count: number, + issues: unknown[] = [], + onFilter?: (filter: unknown) => void, +): LinearImportClient { + return { + projects: async () => ({ + nodes: Array.from({ length: count }, () => ({ + issues: async ({ filter }: { filter: unknown }) => { + onFilter?.(filter); + return { nodes: issues, pageInfo: { hasNextPage: false } }; + }, + })), + }), + } as unknown as LinearImportClient; +} + +describe("Linear issue intake", () => { + test("shares API-key precedence and redirect-safe client setup", () => { + const environment = { CODEX_SECURITY_LINEAR_API_KEY: " environment-key " }; + expect(resolveLinearApiKey(environment, " explicit-key ")).toBe( + "explicit-key", + ); + expect(resolveLinearApiKey(environment)).toBe("environment-key"); + expect(resolveLinearApiKey({})).toBeUndefined(); + expect( + resolveLinearApiKey({ + LINEAR_API_KEY: "intake-key", + LINEAR_ACCESS_TOKEN: "intake-token", + }), + ).toBeUndefined(); + const signal = new AbortController().signal; + const client = projectClient(1); + expect( + createLinearClient( + { apiKey: "synthetic-key", redirect: "follow", signal }, + (options) => { + expect(options).toEqual({ + apiKey: "synthetic-key", + redirect: "error", + signal, + }); + return client; + }, + ), + ).toBe(client); + }); + + test("allows a supplied state filter to select completed issues", async () => { + let filter: unknown; + const issues = await importLinearIssues({ + issues: [], + project: "Security backlog", + filter: '{"state":{"type":{"eq":"completed"}}}', + environment: { LINEAR_API_KEY: "lin_api_SYNTHETIC_SECRET" }, + linearClient: ({ apiKey }) => { + expect(apiKey).toBe("lin_api_SYNTHETIC_SECRET"); + return projectClient( + 1, + [ + { + identifier: "SEC-123", + title: "Recheck a completed issue", + description: null, + url: "https://linear.app/example/issue/SEC-123", + }, + ], + (value) => (filter = value), + ); + }, + }); + + expect(filter).toEqual({ state: { type: { eq: "completed" } } }); + expect(issues).toEqual([ + { + source: "linear", + id: "SEC-123", + url: "https://linear.app/example/issue/SEC-123", + text: "Title: Recheck a completed issue\n\n", + }, + ]); + }); + + test("preserves the workspace selected by an issue URL", async () => { + const selected = "https://linear.app/selected/issue/SEC-123/old-title"; + for (const workspace of ["selected", "different"]) { + const url = `https://linear.app/${workspace}/issue/SEC-123/new-title`; + const importing = importLinearIssues({ + issues: [selected], + environment: { CODEX_SECURITY_LINEAR_API_KEY: "synthetic-key" }, + linearClient: () => + ({ + issue: async (id: string) => { + expect(id).toBe("SEC-123"); + return { + identifier: id, + title: "Synthetic finding", + description: "Synthetic evidence", + url, + }; + }, + }) as unknown as LinearImportClient, + }); + if (workspace === "selected") { + await expect(importing).resolves.toEqual([ + { + source: "linear", + id: "SEC-123", + url, + text: "Title: Synthetic finding\n\nSynthetic evidence", + }, + ]); + } else { + await expect(importing).rejects.toThrow( + "does not match the workspace in the selected URL", + ); + } + } + }); + + test("reports missing, ambiguous, and empty Linear projects", async () => { + for (const [count, message] of [ + [0, 'Linear project "Security backlog" was not found'], + [2, 'Linear project "Security backlog" is ambiguous.'], + [1, 'No open Linear issues matched project "Security backlog"'], + ] as const) { + await expect( + importLinearIssues({ + issues: [], + project: "Security backlog", + environment: { + CODEX_SECURITY_LINEAR_API_KEY: "lin_api_SYNTHETIC_SECRET", + }, + linearClient: () => projectClient(count), + }), + ).rejects.toThrow(message); + } + }); + + test("reports SDK failures without exposing credentials", async () => { + for (const [error, message] of [ + [new AuthenticationLinearError(), "Linear authentication failed."], + [new RatelimitedLinearError(), "Linear request was rate limited."], + [ + new Error("Invalid lin_api_SYNTHETIC_SECRET"), + "Linear request failed: [redacted]", + ], + ] as const) { + await expect( + importLinearIssues({ + issues: ["SEC-123"], + environment: { + CODEX_SECURITY_LINEAR_API_KEY: "lin_api_SYNTHETIC_SECRET", + }, + linearClient: () => + ({ + issue: async () => { + throw error; + }, + }) as unknown as LinearImportClient, + }), + ).rejects.toThrow(message); + } + }); +}); diff --git a/sdk/typescript/tests-ts/publish.test.ts b/sdk/typescript/tests-ts/publish.test.ts index 197f6c0b..abd6b9ad 100644 --- a/sdk/typescript/tests-ts/publish.test.ts +++ b/sdk/typescript/tests-ts/publish.test.ts @@ -157,7 +157,8 @@ function linearApiClient( ) => Promise | void; } = {}, ): NonNullable { - return ({ apiKey, signal }) => { + return ({ apiKey, signal, redirect }) => { + expect(redirect).toBe("error"); options.configured?.(apiKey ?? ""); return { users: async () => ({ nodes: [{ id: "assignee-from-email" }] }),