Skip to content

Commit 91afffe

Browse files
Merge branch 'main' into mdangelo/codex/codex-security-stable-npm-release-20260724
2 parents ef131b1 + f5c7c47 commit 91afffe

6 files changed

Lines changed: 434 additions & 9 deletions

File tree

sdk/typescript/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,12 @@ Python interpreter. The preflight result includes the selected authentication
269269
method and, for an environment API key, its variable name. Authentication and
270270
model access remain unverified until a real scan starts.
271271

272+
Scan progress identifies the selected credential source before Codex starts.
273+
Interactive terminals also show how to retry with ChatGPT when an environment
274+
API key overrides the stored sign-in. Progress remains on stderr so JSON output
275+
stays machine readable. Recoverable failures include safe retry causes and,
276+
when available, the server-provided retry delay.
277+
272278
## Documentation and security
273279

274280
- [CLI quickstart](https://developers.openai.com/codex/security/cli)

sdk/typescript/src/api.ts

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,13 @@ export interface ScanOptions {
120120
failureSeverity?: SeverityLevel;
121121
onOutputArchived?: (archiveDir: string) => void;
122122
onOutputDirReady?: (scanDir: string) => void;
123+
onAuthentication?: (authentication: ScanAuthentication) => void;
123124
onScanStarted?: () => void;
124-
onReconnect?: (attempt: number, maxAttempts: number) => void;
125+
onReconnect?: (
126+
attempt: number,
127+
maxAttempts: number,
128+
details?: ScanReconnectDetails,
129+
) => void;
125130
onWorkerStatus?: (status: ScanWorkerStatus) => void;
126131
onObserverError?: (observer: ScanObserverName, error: unknown) => void;
127132
signal?: AbortSignal;
@@ -138,7 +143,13 @@ export type ScanAuthentication =
138143
verified: false;
139144
};
140145

146+
export interface ScanReconnectDetails {
147+
reason: "rate_limit" | "network" | "authentication" | "authorization";
148+
retryAfterSeconds?: number;
149+
}
150+
141151
type ScanObserverName =
152+
| "onAuthentication"
142153
| "onOutputArchived"
143154
| "onOutputDirReady"
144155
| "onScanStarted"
@@ -353,6 +364,12 @@ export class CodexSecurity {
353364
"OPENAI_API_KEY or CODEX_API_KEY for CI.",
354365
);
355366
}
367+
notifyObserver(
368+
"onAuthentication",
369+
options.onAuthentication,
370+
options.onObserverError,
371+
scanAuthentication(this.#dependencies.environment),
372+
);
356373
const python = await (
357374
this.#dependencies.resolvePluginPython ?? resolvePluginPython
358375
)({
@@ -940,7 +957,11 @@ interface ScanEventRunOptions {
940957
expectation: ScanExpectation;
941958
onFinalize?: () => Promise<void>;
942959
onScanStarted?: () => void;
943-
onReconnect?: (attempt: number, maxAttempts: number) => void;
960+
onReconnect?: (
961+
attempt: number,
962+
maxAttempts: number,
963+
details?: ScanReconnectDetails,
964+
) => void;
944965
onWorkerStatus?: (status: ScanWorkerStatus) => void;
945966
onObserverError?: (observer: ScanObserverName, error: unknown) => void;
946967
}
@@ -1005,6 +1026,7 @@ export async function runScanEvents(
10051026
options.onReconnect,
10061027
options.onObserverError,
10071028
...reconnect,
1029+
reconnectDetails(message),
10081030
);
10091031
}
10101032
}
@@ -1264,6 +1286,71 @@ function reconnectAttempt(message: string): [number, number] | null {
12641286
return attempt <= maxAttempts ? [attempt, maxAttempts] : null;
12651287
}
12661288

1289+
function reconnectDetails(message: string): ScanReconnectDetails | undefined {
1290+
const classification = classifyConnectionFailure(message);
1291+
if (classification !== "rate_limited") {
1292+
if (classification === "network_error") return { reason: "network" };
1293+
if (classification === "unauthorized") return { reason: "authentication" };
1294+
if (classification === "forbidden") return { reason: "authorization" };
1295+
return undefined;
1296+
}
1297+
const delay =
1298+
/\b(?:try again|retry)\s+in\s+(\d{1,6}(?:\.\d{1,3})?)\s*(?:s\b|seconds?\b)/iu.exec(
1299+
message,
1300+
);
1301+
const retryAfterSeconds = delay === null ? NaN : Number(delay[1]);
1302+
return {
1303+
reason: "rate_limit",
1304+
...(Number.isFinite(retryAfterSeconds) &&
1305+
retryAfterSeconds > 0 &&
1306+
retryAfterSeconds <= 3_600
1307+
? { retryAfterSeconds }
1308+
: {}),
1309+
};
1310+
}
1311+
1312+
export function classifyConnectionFailure(
1313+
error: unknown,
1314+
):
1315+
| "rate_limited"
1316+
| "unauthorized"
1317+
| "forbidden"
1318+
| "network_error"
1319+
| "timeout"
1320+
| "unknown" {
1321+
const message = error instanceof Error ? error.message : String(error);
1322+
if (
1323+
/\brate[_ -]?limit(?:ed|[_ -]exceeded)?\b|\b429\b|\btoo many requests\b/iu.test(
1324+
message,
1325+
)
1326+
) {
1327+
return "rate_limited";
1328+
}
1329+
if (
1330+
/\b401\b|\bunauthori[sz]ed\b|\binvalid[_ -](?:api[_ -]?key|authentication|token|credentials?)\b|\b(?:expired|revoked)[_ -](?:api[_ -]?key|token|credentials?)\b|\b(?:api[_ -]?key|token|credentials?)(?: has)? (?:expired|been revoked)\b/iu.test(
1331+
message,
1332+
)
1333+
) {
1334+
return "unauthorized";
1335+
}
1336+
if (
1337+
/\b403\b|\bforbidden\b|\bpermission denied\b|\b(?:model|organization|project) access\b|\b(?:access denied|do not have access|not authorized|insufficient permissions)\b|\bmodel[_ -]?not[_ -]?found\b/iu.test(
1338+
message,
1339+
)
1340+
) {
1341+
return "forbidden";
1342+
}
1343+
if (
1344+
/\b(?:ENOTFOUND|ECONNRESET|ECONNREFUSED|EHOSTUNREACH|ETIMEDOUT)\b|\b(?:network|connection|TLS|DNS)\b|\berror sending request\b/iu.test(
1345+
message,
1346+
)
1347+
) {
1348+
return "network_error";
1349+
}
1350+
if (/\b(?:timed? out|timeout)\b/iu.test(message)) return "timeout";
1351+
return "unknown";
1352+
}
1353+
12671354
export function scanRuntimeCodexConfig(config: JsonObject): JsonObject {
12681355
const hardened = structuredClone(config);
12691356
delete hardened["sandbox_mode"];

sdk/typescript/src/cli.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { pathToFileURL } from "node:url";
2828
import { Cli, z } from "incur";
2929
import { parse as parseToml } from "smol-toml";
3030
import {
31+
classifyConnectionFailure,
3132
CodexSecurity,
3233
scanAuthentication,
3334
type ScanOptions,
@@ -1976,15 +1977,45 @@ async function runScan(
19761977
onOutputDirReady: (path) => {
19771978
scanDir = path;
19781979
},
1980+
onAuthentication: (authentication) => {
1981+
progress?.stopTimer();
1982+
if (authentication.method === "api_key") {
1983+
progress?.stage(
1984+
`Authentication: API key from ${authentication.source}.`,
1985+
);
1986+
if (errorOutput.isTTY === true) {
1987+
progress?.stage(
1988+
process.platform === "win32"
1989+
? "To use a ChatGPT sign-in, unset OPENAI_API_KEY and CODEX_API_KEY, then retry the scan."
1990+
: "Retry with ChatGPT: env -u OPENAI_API_KEY -u CODEX_API_KEY codex-security scan ...",
1991+
);
1992+
}
1993+
} else {
1994+
progress?.stage("Authentication: stored Codex credentials.");
1995+
}
1996+
progress?.startTimer("Preparing scan");
1997+
},
19791998
onScanStarted: () => {
19801999
progress?.stopTimer();
19812000
progress?.startTimer(runningMessage());
19822001
},
1983-
onReconnect: (attempt, maxAttempts) => {
2002+
onReconnect: (attempt, maxAttempts, details) => {
19842003
progress?.stopTimer();
1985-
progress?.stage(
1986-
`Codex connection interrupted; retrying (${attempt}/${maxAttempts})`,
1987-
);
2004+
const message =
2005+
details?.reason === "rate_limit"
2006+
? `Rate limit reached; retrying${
2007+
details.retryAfterSeconds === undefined
2008+
? ""
2009+
: ` in ${details.retryAfterSeconds}s`
2010+
} (${attempt}/${maxAttempts}).`
2011+
: details?.reason === "network"
2012+
? `Network connection interrupted; retrying (${attempt}/${maxAttempts}).`
2013+
: details?.reason === "authentication"
2014+
? `Authentication interrupted; retrying (${attempt}/${maxAttempts}).`
2015+
: details?.reason === "authorization"
2016+
? `Model access interrupted; retrying (${attempt}/${maxAttempts}).`
2017+
: `Codex connection interrupted; retrying (${attempt}/${maxAttempts})`;
2018+
progress?.stage(message);
19882019
progress?.startTimer(runningMessage());
19892020
},
19902021
onWorkerStatus: (status) => {
@@ -2044,7 +2075,7 @@ async function runScan(
20442075
const message =
20452076
failure instanceof OutputInsideProtectedRootError
20462077
? cliErrorMessage(protectedRootErrorMessage(failure))
2047-
: cliErrorMessage(failure);
2078+
: scanFailureMessage(failure);
20482079
if (failure instanceof OutputInsideProtectedRootError) {
20492080
errorOutput.write(`${message}\n`);
20502081
} else {
@@ -2094,6 +2125,23 @@ async function runScan(
20942125
return { exitCode: blockingCount > 0 ? 1 : 0, data: result.toJSON() };
20952126
}
20962127

2128+
function scanFailureMessage(error: unknown): string {
2129+
switch (classifyConnectionFailure(error)) {
2130+
case "unauthorized":
2131+
return "Authentication failed. Sign in again or provide a valid API key.";
2132+
case "forbidden":
2133+
return "The selected credentials cannot access the configured model. Use an account or API key with model access.";
2134+
case "rate_limited":
2135+
return "The configured account reached its rate limit. Wait and retry.";
2136+
case "network_error":
2137+
return "The model service could not be reached. Check your network connection and try again.";
2138+
case "timeout":
2139+
return "The connection timed out. Check your network connection and try again.";
2140+
case "unknown":
2141+
return cliErrorMessage(error);
2142+
}
2143+
}
2144+
20972145
function scanScope(arguments_: ScanArguments): string | null {
20982146
if (arguments_.paths.length > 0) {
20992147
const displayed = arguments_.paths.slice(0, 3).map((path) => {

sdk/typescript/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export type {
44
ScanAuthentication,
55
ScanOptions,
66
ScanPreflight,
7+
ScanReconnectDetails,
78
} from "./api.js";
89
export type { ScanWorkerPhase, ScanWorkerStatus } from "./worker-progress.js";
910
export { CodexLoginHandle } from "./auth.js";

0 commit comments

Comments
 (0)