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
2 changes: 1 addition & 1 deletion docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ for argument do
fi
;;
--output-dir|--workers|--mode|--model|--effort|--provider|\
--knowledge-base|--max-attempts|--plugin-path|--python|\
--knowledge-base|--max-attempts|--max-cost|--plugin-path|--python|\
--codex|--filter-output|--format|\
--scan-prompt-file|--post-scan-prompt-file|\
--token-limit|--token-offset)
Expand Down
3 changes: 2 additions & 1 deletion sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ npx @openai/codex-security scan /path/to/repository --mode deep --workers 2 --su
npx @openai/codex-security install-hook
npx @openai/codex-security bulk-scan
npx @openai/codex-security bulk-scan --model gpt-5.6-terra --effort high
npx @openai/codex-security bulk-scan --workers 4 --mode deep --max-attempts 3
npx @openai/codex-security bulk-scan --workers 4 --mode deep --max-attempts 3 --max-cost 25
npx @openai/codex-security bulk-scan repositories.csv --output-dir /path/outside/repositories/security-scans --workers 4 --knowledge-base /path/to/threat-models --knowledge-base /path/to/architecture.pdf
npx @openai/codex-security bulk-scan repositories.csv --output-dir /path/outside/repositories/security-scans --scan-prompt-file scan.md --post-scan-prompt-file follow-up.md
npx @openai/codex-security scans list /path/to/repository
Expand Down Expand Up @@ -486,6 +486,7 @@ it returns a sealed partial report with any completed findings and lists
unvalidated candidates as follow-up work. Requests already in progress can
finish above the limit; preparing the partial report makes no additional model
requests. Incomplete coverage retains its existing exit code.
For `bulk-scan`, the limit applies separately to each repository attempt.

Run `npx @openai/codex-security scan --help` or `npx @openai/codex-security bulk-scan --help`
for the complete CLI references.
Expand Down
10 changes: 10 additions & 0 deletions sdk/typescript/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1455,6 +1455,13 @@ export async function main(
.positive()
.default(1)
.describe("Maximum scan attempts per repository."),
maxCost: z
.number()
.positive()
.optional()
.describe(
"Stop each repository attempt if estimated USD cost exceeds AMOUNT.",
),
pluginPath: z
.string()
.min(1)
Expand Down Expand Up @@ -1535,6 +1542,9 @@ export async function main(
workers: options.workers,
mode: options.mode,
maxAttempts: options.maxAttempts,
...(options.maxCost === undefined
? {}
: { maxCostUsd: options.maxCost }),
knowledgeBasePaths: options.knowledgeBase,
...prompts,
config: {
Expand Down
23 changes: 21 additions & 2 deletions sdk/typescript/src/multiscan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import Papa from "papaparse";
import type { CodexSecurity } from "./api.js";
import type { CodexSecurityConfig } from "./config.js";
import type { ScanCost } from "./cost.js";
import { safeErrorMessage } from "./errors.js";
import { safeErrorMessage, ScanCostLimitExceededError } from "./errors.js";
import type { CoverageDocument } from "./models.js";
import type { ScanMode } from "./targets.js";
import { resolveTrustedExecutable } from "./trusted-executable.js";
Expand Down Expand Up @@ -62,6 +62,7 @@ export interface MultiscanOptions {
workers: number;
mode: ScanMode;
maxAttempts: number;
maxCostUsd?: number;
scanPrompt?: string;
postScanPrompt?: string;
config: CodexSecurityConfig;
Expand Down Expand Up @@ -198,6 +199,7 @@ async function runCampaign(
let warning: string | undefined;
let coverage: CoverageDocument["completeness"] | undefined;
let cost: Readonly<ScanCost> | null = null;
let exhaustedBudget = false;
try {
await mkdir(dirname(scanDir), { recursive: true, mode: 0o700 });
await rm(checkout, { recursive: true, force: true });
Expand Down Expand Up @@ -233,6 +235,9 @@ async function runCampaign(
...(options.postScanPrompt === undefined
? {}
: { postScanPrompt: options.postScanPrompt }),
...(options.maxCostUsd === undefined
? {}
: { maxCostUsd: options.maxCostUsd }),
onWarning: (warning) =>
options.onProgress?.({ ...progress, status: "started", warning }),
...(options.signal === undefined ? {} : { signal: options.signal }),
Expand All @@ -249,6 +254,10 @@ async function runCampaign(
}
} catch (error) {
if (options.signal?.aborted === true) options.signal.throwIfAborted();
if (error instanceof ScanCostLimitExceededError) {
cost = error.cost;
exhaustedBudget = true;
}
failure = safeErrorMessage(error);
} finally {
await rm(checkout, { recursive: true, force: true });
Expand Down Expand Up @@ -283,6 +292,10 @@ async function runCampaign(
else incomplete += 1;
break;
}
if (exhaustedBudget) {
failed += 1;
break;
}
if (retry === options.maxAttempts - 1) failed += 1;
}
}
Expand Down Expand Up @@ -521,7 +534,10 @@ async function recoverLock(
async function ensureManifest(
path: string,
tasks: MultiscanTask[],
options: Pick<MultiscanOptions, "scanPrompt" | "postScanPrompt">,
options: Pick<
MultiscanOptions,
"scanPrompt" | "postScanPrompt" | "maxCostUsd"
>,
): Promise<void> {
const expected = `${JSON.stringify(
{
Expand All @@ -533,6 +549,9 @@ async function ensureManifest(
...(options.postScanPrompt === undefined
? {}
: { postScanPrompt: options.postScanPrompt }),
...(options.maxCostUsd === undefined
? {}
: { maxCostUsd: options.maxCostUsd }),
},
null,
2,
Expand Down
2 changes: 2 additions & 0 deletions sdk/typescript/tests-ts/container-entrypoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ describe("customer container entrypoint", () => {
testPosix("accepts CSVs after global and bulk-scan options", async () => {
for (const arguments_ of [
["bulk-scan", "--workers", "2", "/input/repositories.csv"],
["bulk-scan", "--max-cost", "12.50", "/input/repositories.csv"],
["bulk-scan", "bulk-scan", "--output-dir", "/output"],
[
"--format",
Expand Down Expand Up @@ -260,6 +261,7 @@ describe("customer container entrypoint", () => {
for (const arguments_ of [
["bulk-scan"],
["bulk-scan", "--workers", "8"],
["bulk-scan", "--max-cost", "12.50"],
["bulk-scan", "--scan-prompt-file", "prompt.md"],
["bulk-scan", "--post-scan-prompt-file", "post-prompt.md"],
["--format", "json", "bulk-scan", "--mode", "deep"],
Expand Down
88 changes: 88 additions & 0 deletions sdk/typescript/tests-ts/multiscan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { hostname, tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, spyOn, test } from "bun:test";
import { main } from "../src/cli.js";
import { ScanCostLimitExceededError } from "../src/errors.js";
import type { ScanResult } from "../src/result.js";
import { buildGitHubCredentialArgs, runMultiscan } from "../src/multiscan.js";
import { resolveTrustedExecutable } from "../src/trusted-executable.js";
Expand Down Expand Up @@ -204,11 +205,13 @@ describe("multiscan", () => {
"Review boundaries.\n\nFocus on authentication, authorization.",
);
expect(scanOptions.postScanPrompt).toBe("Draft confirmed fixes.");
expect(scanOptions.maxCostUsd).toBe(12.5);
return await completedScan(scanOptions.outputDir!);
}),
{
scanPrompt: "Review boundaries.",
postScanPrompt: "Draft confirmed fixes.",
maxCostUsd: 12.5,
},
),
);
Expand All @@ -222,6 +225,7 @@ describe("multiscan", () => {
).toMatchObject({
scanPrompt: "Review boundaries.",
postScanPrompt: "Draft confirmed fixes.",
maxCostUsd: 12.5,
tasks: [
{ id: "payments", prompt: "Focus on authentication, authorization." },
],
Expand Down Expand Up @@ -259,6 +263,89 @@ describe("multiscan", () => {
]);
});

test("records an exhausted repository budget without retrying the scan", async () => {
const paths = await fixture();
const source = await repository(paths.root, "over-budget");
await writeFile(
paths.input,
`id,repository,revision\nover-budget,${source.path},${source.revision}\n`,
);
const cost = {
model: "gpt-5.6-sol",
inputTokens: 1_250,
cachedInputTokens: 200,
cacheWriteInputTokens: 0,
outputTokens: 30,
estimatedUsd: 25.25,
};
let attempts = 0;

const summary = await runMultiscan(
options(
paths,
client(async (_repository, scanOptions = {}) => {
attempts += 1;
throw new ScanCostLimitExceededError(
25,
cost,
scanOptions.outputDir!,
);
}),
{ maxAttempts: 3, maxCostUsd: 25 },
),
);

expect(attempts).toBe(1);
expect(summary).toMatchObject({ completed: 0, failed: 1 });
expect(await results(summary.resultsPath)).toMatchObject([
{ id: "over-budget", status: "failed", attempt: 1, cost },
]);
});

test("forwards a bulk CLI cost limit and rejects zero", async () => {
const paths = await fixture();
const source = await repository(paths.root, "sample");
await writeFile(
paths.input,
`id,repository,revision\nsample,${source.path},${source.revision}\n`,
);
const stdout = capture();
const stderr = capture();
let scanOptions: unknown;

expect(
await main(
[
"bulk-scan",
"repositories.csv",
"--output-dir",
"results",
"--max-cost",
"12.5",
"--json",
],
stdout.stream,
stderr.stream,
dependencies({
currentDirectory: paths.root,
onTurn: (_repository, options) => (scanOptions = options),
}),
),
).toBe(0);
expect(scanOptions).toMatchObject({ maxCostUsd: 12.5 });

const invalid = capture();
expect(
await main(
["bulk-scan", "--max-cost=0"],
capture().stream,
invalid.stream,
dependencies({ currentDirectory: paths.root }),
),
).toBe(2);
expect(invalid.text()).toContain("expected number to be >0");
});

test("surfaces optional post-scan warnings without failing completed scans", async () => {
const paths = await fixture();
const source = await repository(paths.root, "follow-up-warning");
Expand Down Expand Up @@ -1374,6 +1461,7 @@ describe("multiscan", () => {
for (const prompts of [
{ scanPrompt: "Review different boundaries." },
{ postScanPrompt: "Draft confirmed fixes." },
{ maxCostUsd: 12.5 },
]) {
await expect(
runMultiscan(options(paths, security, prompts)),
Expand Down
Loading