diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 347ba21c..a8ca4894 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -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) diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index e32c4e8d..0a08489b 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -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 @@ -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. diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index e8483157..8d89d99f 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -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) @@ -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: { diff --git a/sdk/typescript/src/multiscan.ts b/sdk/typescript/src/multiscan.ts index 2c96206c..fe151774 100644 --- a/sdk/typescript/src/multiscan.ts +++ b/sdk/typescript/src/multiscan.ts @@ -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"; @@ -62,6 +62,7 @@ export interface MultiscanOptions { workers: number; mode: ScanMode; maxAttempts: number; + maxCostUsd?: number; scanPrompt?: string; postScanPrompt?: string; config: CodexSecurityConfig; @@ -198,6 +199,7 @@ async function runCampaign( let warning: string | undefined; let coverage: CoverageDocument["completeness"] | undefined; let cost: Readonly | null = null; + let exhaustedBudget = false; try { await mkdir(dirname(scanDir), { recursive: true, mode: 0o700 }); await rm(checkout, { recursive: true, force: true }); @@ -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 }), @@ -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 }); @@ -283,6 +292,10 @@ async function runCampaign( else incomplete += 1; break; } + if (exhaustedBudget) { + failed += 1; + break; + } if (retry === options.maxAttempts - 1) failed += 1; } } @@ -521,7 +534,10 @@ async function recoverLock( async function ensureManifest( path: string, tasks: MultiscanTask[], - options: Pick, + options: Pick< + MultiscanOptions, + "scanPrompt" | "postScanPrompt" | "maxCostUsd" + >, ): Promise { const expected = `${JSON.stringify( { @@ -533,6 +549,9 @@ async function ensureManifest( ...(options.postScanPrompt === undefined ? {} : { postScanPrompt: options.postScanPrompt }), + ...(options.maxCostUsd === undefined + ? {} + : { maxCostUsd: options.maxCostUsd }), }, null, 2, diff --git a/sdk/typescript/tests-ts/container-entrypoint.test.ts b/sdk/typescript/tests-ts/container-entrypoint.test.ts index 796678a0..ff25a177 100644 --- a/sdk/typescript/tests-ts/container-entrypoint.test.ts +++ b/sdk/typescript/tests-ts/container-entrypoint.test.ts @@ -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", @@ -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"], diff --git a/sdk/typescript/tests-ts/multiscan.test.ts b/sdk/typescript/tests-ts/multiscan.test.ts index c8244933..3dc00390 100644 --- a/sdk/typescript/tests-ts/multiscan.test.ts +++ b/sdk/typescript/tests-ts/multiscan.test.ts @@ -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"; @@ -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, }, ), ); @@ -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." }, ], @@ -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"); @@ -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)),