Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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