Skip to content

Commit 8f4348e

Browse files
mldangelo-oaiMBemeraGautamSharma99Haiduongcablemangeshraut712
authored
fix: consolidate contributor runtime-security hardening (#152)
* Redact persisted scan failure credentials * fix: bound login and skill child shutdown * fix: bound authentication and skill output * fix: bound configured plugin traversal * fix: bound cost session events * fix: fail the scan on every turn.failed payload A failed turn was only honored when its error payload was an object with a string message. Every other shape fell through the event loop, and because a turn.completed had already been observed the scan was returned to the caller as successful and recorded with complete-scan. Treat any turn.failed as terminal. Only error.message is reused, because that is the single shape the previous code already surfaced; no other shape is forwarded or stringified, since this message reaches fail-scan --message and is stored in scans.failure_message without redaction. * fix: keep scan cleanup failures from replacing the scan result The scan finally block awaited knowledge-base cleanup, target-paths removal, and the credential home release without guarding any of them. A rejection in a finally block replaces the pending exception, so a cleanup failure discarded the outcome the try and catch blocks had already produced and the caller was handed an unrelated filesystem error instead of the reason the scan failed. The catch block had already recorded the real message through fail-scan, so the persisted scan history and the reported error disagreed. Removing the temporary inputs is best effort and is now reported through onWarning instead of deciding the result. Releasing the credential home lock is not: it only marks itself done once the lock directory is gone, so a failed release leaves an owner.json naming a live process that stale-lock recovery will not reclaim. That failure still propagates, and is downgraded to a warning only when the scan already failed and its error is the one worth keeping. The failed-scan marker is recorded on entry to the catch block because requireOpen and throwIfAborted raise a different error for the same failed scan, the release keeps its own finally so reporting the earlier failures cannot skip it, and the warning formatter is total so no hostile reason can throw in place of the result. * fix: prioritize active preflight config * fix: make bounded authentication shutdown deterministic Co-authored-by: mangeshraut712 <mbr63@drexel.edu> * test: restrict POSIX process signal propagation to supported platforms * fix: close runtime credential and subprocess review gaps * fix: report local scan failures without connectivity advice classifyConnectionFailure matches bare words anywhere in a message, and the unauthorized, forbidden and rate-limited branches of scanFailureMessage replace the message entirely with fixed advice. A local failure whose text merely contained one of those words was therefore reported as a credential problem, and its own text reached neither stderr nor the JSON error field. The reported trigger is "permission denied", the standard strerror for EACCES: a read-only TMPDIR surfaced as "The stored ChatGPT credentials cannot access the configured model." Skip classification for failures that cannot originate from the model transport -- InvalidTargetError, OutputDirectoryError, ConfigurationError, PluginPythonUnavailableError, and filesystem syscall errno codes -- so they keep their own message. Network errno codes are deliberately excluded from that set because they are genuine transport failures. Advice still replaces the underlying text for real transport failures. Upstream authentication and authorization errors can name the organization or project, and cli-authentication.test.ts pins that they must not reach stderr. Fixes #36 * fix: retain credential redaction for local scan failures * fix: harden credential redaction and authentication boundaries * fix: exclude unrelated delegated cost sessions safely * fix: keep credential homes private under trusted ancestry Reject non-sticky or untrusted parents for durable auth state, re-check on every credential-home use path, harden auth.json, and pin identity for the lock session so shared-volume rename races cannot steal auth. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: preserve safe credential login and shared ancestry contracts * test: use private credential homes for authentication fixtures * fix: redact PEM credentials and revalidate Windows home ACLs * test: synchronously flush synthetic ChatGPT login prompts * fix: atomically publish ambient auth credentials * fix: redact JSON-quoted credentials before persistent failures * fix: redact recursively escaped credential strings * fix: close credential redaction and home identity races * fix: seal credential locking and redact custom authorization schemes * fix: bound login instructions and redact incomplete credentials * fix: preserve padded authorization credentials during redaction * fix: redact camel-case credential fields before persistence * fix: settle terminated skill processes and portable credential imports * fix: redact truncated private-key diagnostics completely * fix: redact key-value custom authorization credentials --------- Co-authored-by: Matthew Bright <matthew.bright@hotmail.com> Co-authored-by: GautamSharma99 <gautamsharma99067@gmail.com> Co-authored-by: Hai Duong <haiduong.nguyen2712@gmail.com> Co-authored-by: mangeshraut712 <mbr63@drexel.edu> Co-authored-by: mldangelo <michael.l.dangelo@gmail.com> Co-authored-by: batmanknows <122247678+batmnnn@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Ian Webster <ianw@openai.com>
1 parent 908bdb7 commit 8f4348e

17 files changed

Lines changed: 2618 additions & 320 deletions

sdk/typescript/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,9 @@ including cached input and cache writes; fees and surcharges are not included.
380380

381381
Use `--max-cost USD` to stop a scan, including its delegated workers, when its
382382
running cost exceeds the limit. Partial results are preserved. Requests
383-
already in progress can finish above the limit.
383+
already in progress can finish above the limit. Cost tracking accepts Codex
384+
session events up to 1 MiB; an oversized event stops the scan because its
385+
running cost can no longer be verified safely.
384386

385387
Run `npx @openai/codex-security scan --help` or `npx @openai/codex-security bulk-scan --help`
386388
for the complete CLI references.

sdk/typescript/src/api.ts

Lines changed: 138 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
OutputDirectoryError,
3333
OutputInsideProtectedRootError,
3434
type ProtectedScanPathKind,
35+
redactedErrorMessage,
3536
ScanCostLimitExceededError,
3637
ScanInterruptedError,
3738
} from "./errors.js";
@@ -311,6 +312,7 @@ export class CodexSecurity {
311312
let knowledgeBase: PreparedKnowledgeBase | null = null;
312313
let costTracker: ScanCostTracker | null = null;
313314
let releaseCredentialHome: (() => Promise<void>) | null = null;
315+
let scanFailure = false;
314316
let completionCost: ScanCost | null = null;
315317
let activeScan: {
316318
id: string;
@@ -397,6 +399,14 @@ export class CodexSecurity {
397399
) {
398400
await this.#refreshPersistentRuntime(runtime, scanEnvironment, signal);
399401
}
402+
const effectiveConfig =
403+
runtime.effectiveConfig ?? (await mergedCodexConfig(this.config));
404+
if (runtime.configPath !== undefined) {
405+
await writeCodexConfig(
406+
runtime.configPath,
407+
scanPreflightCodexConfig(effectiveConfig, repo),
408+
);
409+
}
400410
const runtimeHome = await realpath(runtime.codexHome);
401411
requireOutputOutsideRepository(protectedRoot, runtimeHome, "runtime");
402412
if (
@@ -533,8 +543,6 @@ export class CodexSecurity {
533543
mode,
534544
pluginVersion: runtime.plugin.version,
535545
};
536-
const effectiveConfig =
537-
runtime.effectiveConfig ?? (await mergedCodexConfig(this.config));
538546
const { model } = scanModelConfiguration(effectiveConfig);
539547
validateScanCostLimit(options.maxCostUsd, model);
540548
const tracker = new ScanCostTracker({
@@ -562,6 +570,7 @@ export class CodexSecurity {
562570
costTracker = tracker;
563571
const recipe = scanRecipe(
564572
repo,
573+
protectedRoot,
565574
normalized,
566575
mode,
567576
expectation.repositoryRevision,
@@ -832,6 +841,9 @@ export class CodexSecurity {
832841
}
833842
return result;
834843
} catch (error) {
844+
// Recorded first: everything below can throw a different error for this same failed
845+
// scan, and cleanup must treat all of those as a failure it is not allowed to mask.
846+
scanFailure = true;
835847
const snapshot = await costTracker?.stop().catch(() => null);
836848
const failure =
837849
signal.reason instanceof ScanCostLimitExceededError
@@ -843,11 +855,10 @@ export class CodexSecurity {
843855
"fail-scan",
844856
"--scan-id",
845857
activeScan.id,
858+
// Redact before truncating: the stored message is read back by
859+
// `scans show` and travels inside the results directory.
846860
"--message",
847-
(failure instanceof Error
848-
? failure.message
849-
: String(failure)
850-
).slice(0, 2400),
861+
redactedErrorMessage(failure).slice(0, 2400),
851862
...(snapshot?.cost
852863
? ["--cost-json", JSON.stringify(snapshot.cost)]
853864
: []),
@@ -860,13 +871,37 @@ export class CodexSecurity {
860871
}
861872
throw failure;
862873
} finally {
874+
// Removing the temporary scan inputs is best effort. A throw here would replace the
875+
// outcome the try and catch blocks already produced, so these failures are reported
876+
// as warnings: a scan that failed has to say why it failed, not why its temporary
877+
// files outlived it. The whole step is guarded so that a cleanup which rejects, or
878+
// throws synchronously, still cannot skip the credential lock release below.
863879
try {
864-
await Promise.all([
880+
for (const cleanup of await Promise.allSettled([
865881
knowledgeBase?.cleanup(),
866882
removeTargetPathsFile(targetPathsFile),
867-
]);
883+
])) {
884+
if (cleanup.status === "rejected") {
885+
warnCleanupFailed(options, cleanup.reason);
886+
}
887+
}
888+
} catch (error) {
889+
warnCleanupFailed(options, error);
868890
} finally {
869-
await releaseCredentialHome?.();
891+
// Releasing the credential home lock is not best effort, so it keeps its own
892+
// finally and runs even if reporting the failures above went wrong. The release
893+
// only marks itself done once the lock directory is gone, so a failure leaves an
894+
// owner.json naming this still-running process; recoverStaleCredentialHomeLock
895+
// then refuses to reclaim it because that pid is alive, and later scans in this
896+
// process wait on a lock nothing frees. Reporting success while leaving the client
897+
// in that state is worse than failing, so the failure is only downgraded to a
898+
// warning when the scan already failed and that error is the one worth keeping.
899+
try {
900+
await releaseCredentialHome?.();
901+
} catch (error) {
902+
if (!scanFailure) throw error;
903+
warnCleanupFailed(options, error);
904+
}
870905
}
871906
}
872907
}
@@ -1319,6 +1354,28 @@ export async function initialCredentialsAvailable(
13191354
return await importer(ambientHome, isolatedHome);
13201355
}
13211356

1357+
// Reports a cleanup failure without letting it decide the result of the scan. Only the
1358+
// message is forwarded, and it reaches the onWarning observer alone: unlike the fail-scan
1359+
// path it is never written to the workbench, so it adds no persisted, unredacted text.
1360+
function warnCleanupFailed(
1361+
options: Pick<ScanOptions, "onWarning" | "onObserverError">,
1362+
reason: unknown,
1363+
): void {
1364+
// This runs where a throw would replace the scan result, so every step is inside the
1365+
// guard: reading the reason, coercing it, and reading the observers off the options can
1366+
// each throw for a sufficiently hostile value, and none of them may become the outcome
1367+
// of the scan. Losing a warning is the correct trade against losing the result.
1368+
try {
1369+
const message = String(reason instanceof Error ? reason.message : reason);
1370+
notifyObserver(
1371+
"onWarning",
1372+
options.onWarning,
1373+
options.onObserverError,
1374+
`Could not clean up after the Codex Security scan: ${message}`,
1375+
);
1376+
} catch {}
1377+
}
1378+
13221379
async function removeTargetPathsFile(path: string | null): Promise<void> {
13231380
if (path === null) return;
13241381
try {
@@ -1395,12 +1452,8 @@ export async function runScanEvents(
13951452
} else if (event.type === "turn.completed") {
13961453
status = "completed";
13971454
usage = event["usage"];
1398-
} else if (
1399-
event.type === "turn.failed" &&
1400-
isRecord(event["error"]) &&
1401-
typeof event["error"]["message"] === "string"
1402-
) {
1403-
throw new CodexSecurityError(event["error"]["message"]);
1455+
} else if (event.type === "turn.failed") {
1456+
throw new CodexSecurityError(turnFailureMessage(event["error"]));
14041457
} else if (
14051458
event.type === "error" &&
14061459
typeof event["message"] === "string"
@@ -1556,6 +1609,7 @@ function targetInstruction(target: NormalizedTarget): string {
15561609

15571610
function scanRecipe(
15581611
repository: string,
1612+
activeProjectPath: string,
15591613
target: NormalizedTarget,
15601614
mode: ScanMode,
15611615
repositoryRevision: string | null,
@@ -1578,7 +1632,7 @@ function scanRecipe(
15781632
mode,
15791633
...(repositoryRevision === null ? {} : { repositoryRevision }),
15801634
pluginVersion,
1581-
config: scanPreflightCodexConfig(effectiveConfig),
1635+
config: scanPreflightCodexConfig(effectiveConfig, activeProjectPath),
15821636
...(failOnSeverity === undefined ? {} : { failOnSeverity }),
15831637
...(knowledgeBasePaths === undefined ? {} : { knowledgeBasePaths }),
15841638
...(maxCostUsd === undefined ? {} : { maxCostUsd }),
@@ -1758,6 +1812,21 @@ function reconnectDetails(message: string): ScanReconnectDetails | undefined {
17581812
};
17591813
}
17601814

1815+
// A failed turn must fail the scan whatever its error payload looks like.
1816+
//
1817+
// Only `error.message` is reused, because that is the single shape the previous
1818+
// code already surfaced. No other shape is forwarded or stringified: this message
1819+
// reaches `fail-scan --message` and is stored in `scans.failure_message` without
1820+
// redaction, so widening what is copied out of the payload would add a new
1821+
// credential-disclosure path to persistent scan history.
1822+
function turnFailureMessage(error: unknown): string {
1823+
if (isRecord(error) && typeof error["message"] === "string") {
1824+
const message = error["message"].trim();
1825+
if (message.length > 0) return error["message"];
1826+
}
1827+
return "The Codex Security scan turn failed without a readable error message.";
1828+
}
1829+
17611830
export function classifyConnectionFailure(
17621831
error: unknown,
17631832
):
@@ -1833,7 +1902,10 @@ export function scanRuntimeCodexConfig(
18331902
};
18341903
}
18351904

1836-
export function scanPreflightCodexConfig(config: JsonObject): JsonObject {
1905+
export function scanPreflightCodexConfig(
1906+
config: JsonObject,
1907+
activeProjectPath?: string,
1908+
): JsonObject {
18371909
const safeString = (value: unknown, maxLength: number): value is string =>
18381910
typeof value === "string" &&
18391911
value.length > 0 &&
@@ -1903,18 +1975,41 @@ export function scanPreflightCodexConfig(config: JsonObject): JsonObject {
19031975
}
19041976
return result;
19051977
};
1978+
const prioritizedEntries = (
1979+
value: Record<string, unknown>,
1980+
priority: string | undefined,
1981+
): [string, unknown][] => {
1982+
const entries = Object.entries(value);
1983+
if (priority === undefined || !Object.hasOwn(value, priority)) {
1984+
return entries;
1985+
}
1986+
return [
1987+
[priority, value[priority]],
1988+
...entries.filter(([key]) => key !== priority),
1989+
];
1990+
};
19061991

19071992
const result = executionConfig(config);
1908-
if (safeProfileName(config["profile"])) {
1909-
result["profile"] = config["profile"];
1993+
const selectedProfile = safeProfileName(config["profile"])
1994+
? config["profile"]
1995+
: undefined;
1996+
if (selectedProfile !== undefined) {
1997+
result["profile"] = selectedProfile;
19101998
}
19111999
const profiles = config["profiles"];
19122000
if (isRecord(profiles)) {
19132001
const sanitized: JsonObject = {};
1914-
for (const [name, profile] of Object.entries(profiles).slice(0, 256)) {
2002+
let accepted = 0;
2003+
for (const [name, profile] of prioritizedEntries(
2004+
profiles,
2005+
selectedProfile,
2006+
)) {
19152007
if (!safeProfileName(name) || !isRecord(profile)) continue;
19162008
const projected = executionConfig(profile as JsonObject);
1917-
if (Object.keys(projected).length > 0) sanitized[name] = projected;
2009+
if (Object.keys(projected).length === 0) continue;
2010+
sanitized[name] = projected;
2011+
accepted += 1;
2012+
if (accepted === 256) break;
19182013
}
19192014
if (Object.keys(sanitized).length > 0) result["profiles"] = sanitized;
19202015
}
@@ -1927,13 +2022,34 @@ export function scanPreflightCodexConfig(config: JsonObject): JsonObject {
19272022
const projects = config["projects"];
19282023
if (isRecord(projects)) {
19292024
const sanitized: JsonObject = {};
1930-
for (const [path, project] of Object.entries(projects).slice(0, 256)) {
2025+
let accepted = 0;
2026+
const activeProjectRoot =
2027+
activeProjectPath === undefined
2028+
? undefined
2029+
: Object.keys(projects)
2030+
.filter((path) => {
2031+
if (!safeString(path, 4096) || !isAbsolute(path)) return false;
2032+
const remaining = relative(path, activeProjectPath);
2033+
return (
2034+
remaining === "" ||
2035+
(remaining !== ".." &&
2036+
!remaining.startsWith(`..${sep}`) &&
2037+
!isAbsolute(remaining))
2038+
);
2039+
})
2040+
.sort((left, right) => right.length - left.length)[0];
2041+
for (const [path, project] of prioritizedEntries(
2042+
projects,
2043+
activeProjectRoot ?? activeProjectPath,
2044+
)) {
19312045
if (!safeString(path, 4096) || !isAbsolute(path) || !isRecord(project)) {
19322046
continue;
19332047
}
19342048
const trust = project["trust_level"];
19352049
if (trust !== "trusted" && trust !== "untrusted") continue;
19362050
sanitized[path] = { trust_level: trust };
2051+
accepted += 1;
2052+
if (accepted === 256) break;
19372053
}
19382054
if (Object.keys(sanitized).length > 0) result["projects"] = sanitized;
19392055
}

0 commit comments

Comments
 (0)