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
1 change: 1 addition & 0 deletions sdk/typescript/scripts/check-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ const distFiles = new Set(
"auth",
"bulk-scan-discovery",
"cli",
"cloud-publish",
"codex-prompt",
"config",
"contract",
Expand Down
63 changes: 53 additions & 10 deletions sdk/typescript/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
type ScanPreflight,
} from "./api.js";
import { accountStatus } from "./auth.js";
import { publishScanToCloud } from "./cloud-publish.js";
import {
createBulkScanDiscoveryDependencies,
runBulkScanWizard,
Expand Down Expand Up @@ -944,6 +945,7 @@ interface CliDependencies {
scanAuthenticationPrompt?: Pick<BulkScanPrompt, "isInteractive" | "select">;
publishPrompt?: Pick<BulkScanPrompt, "isInteractive" | "select">;
publishScan?: typeof publishScan;
publishScanToCloud?: typeof publishScanToCloud;
confirmPatchReview?: (question: string) => Promise<boolean>;
patchEditor?: (
repository: string,
Expand Down Expand Up @@ -1843,7 +1845,13 @@ export async function main(
.describe("Completed scan directory; omit to select a saved scan."),
}),
options: z.object({
to: z.literal("linear").describe("Publication destination."),
// Cloud remains an internal destination, omitted from public discovery.
to: z
.string()
.refine((value) => value === "linear" || value === "cloud", {
message: "Unsupported publication destination. Use --to linear.",
})
.describe("Publication destination (linear)."),
linearTeam: optionValue("--linear-team")
.optional()
.describe("Linear team ID; defaults to CODEX_SECURITY_LINEAR_TEAM."),
Expand Down Expand Up @@ -1879,10 +1887,27 @@ export async function main(
const onTerminate = (): void => cancel("SIGTERM");
let observingSignals = false;
try {
const linearApiKey = resolveLinearApiKey(
dependencies.environment,
options.linearApiKey,
);
if (
options.to === "cloud" &&
[
options.linearTeam,
options.linearApiKey,
options.linearProject,
options.project,
options.linearAssignee,
].some((value) => value !== undefined)
) {
throw new CodexSecurityError(
"Cloud publication cannot be combined with Linear options.",
);
}
const linearApiKey =
options.to === "linear"
? resolveLinearApiKey(
dependencies.environment,
options.linearApiKey,
)
: undefined;
const assigneeId = options.linearAssignee?.trim();
if (options.linearAssignee !== undefined && !assigneeId) {
throw new CodexSecurityError("--linear-assignee must not be empty.");
Expand All @@ -1894,8 +1919,9 @@ export async function main(
}
const teamId =
options.linearTeam?.trim() ||
dependencies.environment["CODEX_SECURITY_LINEAR_TEAM"]?.trim();
if (!teamId) {
dependencies.environment["CODEX_SECURITY_LINEAR_TEAM"]?.trim() ||
"";
if (options.to === "linear" && !teamId) {
throw new CodexSecurityError(
"--linear-team or CODEX_SECURITY_LINEAR_TEAM is required.",
);
Expand Down Expand Up @@ -1934,7 +1960,7 @@ export async function main(
}).prompt;
if (!prompt.isInteractive()) {
throw new CodexSecurityError(
"Interactive scan selection requires a terminal. Provide a completed scan directory: codex-security publish scan /path/to/sealed-scan --to linear --linear-team TEAM_ID.",
`Interactive scan selection requires a terminal. Provide a completed scan directory: codex-security publish scan /path/to/sealed-scan --to ${options.to}${options.to === "linear" ? " --linear-team TEAM_ID" : ""}.`,
);
}
const saved = await dependencies.runWorkbench([
Expand Down Expand Up @@ -2081,6 +2107,21 @@ export async function main(
repositories.get(scanDir) ?? basename(scanDir);
}

if (options.to === "cloud") {
dependencies.addSignalListener("SIGINT", onInterrupt);
dependencies.addSignalListener("SIGTERM", onTerminate);
observingSignals = true;
const result = await (
dependencies.publishScanToCloud ?? publishScanToCloud
)(resolve(dependencies.currentDirectory(), scanDir), {
environment: dependencies.environment,
dryRun: options.dryRun,
signal: controller.signal,
});
controller.signal.throwIfAborted();
return { ...result };
}

const progress = new PublicationProgressPresenter(
errorOutput,
dependencies,
Expand All @@ -2098,7 +2139,7 @@ export async function main(
result = await (dependencies.publishScan ?? publishScan)(
resolve(dependencies.currentDirectory(), scanDir),
{
destination: options.to,
destination: "linear",
teamId,
...(projectId === undefined ? {} : { projectId }),
dryRun: options.dryRun,
Expand Down Expand Up @@ -2154,7 +2195,9 @@ export async function main(
errorOutput.write(`codex-security: ${reason}${recovery}\n`);
exitCode = signal === "SIGINT" ? 130 : 143;
} else {
errorOutput.write(`codex-security: ${errorMessage(error)}\n`);
errorOutput.write(
`codex-security: ${options.to === "cloud" ? safeErrorMessage(error) : errorMessage(error)}\n`,
);
exitCode = 2;
}
return undefined;
Expand Down
177 changes: 177 additions & 0 deletions sdk/typescript/src/cloud-publish.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { z } from "incur";
import { parse as parseToml } from "smol-toml";
import { loadContract } from "./contract.js";
import { AuthenticationRequiredError, CodexSecurityError } from "./errors.js";
import type { Finding } from "./models.js";
import {
bundledPluginRoot,
codexSecurityCredentialAllowsAmbientImport,
codexSecurityCredentialHome,
codexSecurityHasStoredFileCredentials,
expandHome,
} from "./runtime.js";

const CLOUD_PUBLISH_URL =
"https://chatgpt.com/backend-api/aardvark/cli/findings";
const CHATGPT_LOGIN_REQUIRED =
"Cloud publication requires a file-backed ChatGPT login. Sign in with ChatGPT using Codex file credential storage, then retry.";

const credentialsSchema = z.object({
auth_mode: z.literal("chatgpt").optional(),
OPENAI_API_KEY: z.null().optional(),
tokens: z.object({
access_token: z.string().trim().min(1),
account_id: z.string().trim().min(1),
}),
});

const receiptSchema = z.object({
status: z.literal("accepted"),
finding_ids: z.array(z.string().min(1)),
finding_count: z.number().int().positive(),
});

export interface CloudPublicationResult {
scanId: string;
findingIds: string[];
findingCount: number;
dryRun?: true;
findings?: Finding[];
}

export async function publishScanToCloud(
scanDirectory: string,
dependencies: {
environment?: NodeJS.ProcessEnv;
fetch?: (url: string, options: RequestInit) => Promise<Response>;
signal?: AbortSignal;
dryRun?: boolean;
} = {},
): Promise<CloudPublicationResult> {
const { manifest, findings } = await loadContract(scanDirectory, {
pluginRoot: await bundledPluginRoot(),
signal: dependencies.signal,
});
if (findings.findings.length === 0) {
throw new CodexSecurityError(
"The completed scan has no findings to publish.",
);
}
dependencies.signal?.throwIfAborted();
if (dependencies.dryRun) {
return {
scanId: manifest.scan.id,
findingIds: [],
findingCount: findings.findings.length,
dryRun: true,
findings: findings.findings,
};
}
const credentials = await readCloudCredentials(
dependencies.environment ?? process.env,
);
const timeout = AbortSignal.timeout(30_000);
const signal = dependencies.signal
? AbortSignal.any([dependencies.signal, timeout])
: timeout;
let response: Response;
try {
response = await (dependencies.fetch ?? globalThis.fetch)(
CLOUD_PUBLISH_URL,
{
method: "POST",
headers: {
Authorization: `Bearer ${credentials.access_token}`,
"ChatGPT-Account-ID": credentials.account_id,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
schemaVersion: "1.0",
scan: manifest.scan,
findings: findings.findings,
}),
redirect: "error",
signal,
},
);
} catch {
// A lost response does not establish whether the server accepted the POST.
throw new CodexSecurityError(
"Cloud publication was not confirmed. The request was not retried; check whether it was accepted before submitting again.",
);
}
if (!response.ok) {
await response.body?.cancel().catch(() => undefined);
const detail =
response.status === 401
? "Sign in with ChatGPT again before retrying."
: response.status === 403
? "The signed-in account is not authorized to publish to Cloud."
: response.status === 404
? "Cloud publication is not available for this account or deployment."
: "The request was not retried.";
throw new CodexSecurityError(
`Cloud publication failed (HTTP ${response.status}). ${detail}`,
);
}
const receipt = receiptSchema.safeParse(
await response.json().catch(() => undefined),
);
if (
(response.status !== 200 && response.status !== 201) ||
!receipt.success ||
receipt.data.finding_count !== findings.findings.length ||
receipt.data.finding_ids.length !== findings.findings.length
) {
throw new CodexSecurityError(
"Cloud publication returned an invalid acceptance receipt. Check whether the request was accepted before submitting again.",
);
}
return {
scanId: manifest.scan.id,
findingIds: receipt.data.finding_ids,
findingCount: receipt.data.finding_count,
};
}

async function readCloudCredentials(environment: NodeJS.ProcessEnv) {
let home = expandHome(
environment["CODEX_HOME"]?.trim() || "~/.codex",
environment,
);
const dedicatedHome = codexSecurityCredentialHome(environment);
if (existsSync(dedicatedHome)) {
if (!(await codexSecurityCredentialAllowsAmbientImport(dedicatedHome))) {
throw new AuthenticationRequiredError(CHATGPT_LOGIN_REQUIRED);
}
if (await codexSecurityHasStoredFileCredentials(dedicatedHome)) {
home = dedicatedHome;
} else if (existsSync(join(dedicatedHome, "config.toml"))) {
// Do not silently switch accounts when the dedicated login may be in a keyring.
throw new AuthenticationRequiredError(CHATGPT_LOGIN_REQUIRED);
}
}
try {
const configPath = join(home, "config.toml");
if (existsSync(configPath)) {
const config = parseToml(await readFile(configPath, "utf8"));
if (
config["cli_auth_credentials_store"] === "keyring" ||
config["cli_auth_credentials_store"] === "auto"
) {
throw new AuthenticationRequiredError(CHATGPT_LOGIN_REQUIRED);
}
}
const credentials = credentialsSchema.safeParse(
JSON.parse(await readFile(join(home, "auth.json"), "utf8")),
);
if (credentials.success) return credentials.data.tokens;
} catch {
// Parsing and filesystem diagnostics must not reflect credential contents.
}
throw new AuthenticationRequiredError(CHATGPT_LOGIN_REQUIRED);
}
Loading
Loading