diff --git a/README.md b/README.md index e0344ac..c70d402 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ npm install @openai/codex-security npx @openai/codex-security login npx @openai/codex-security scan . npx @openai/codex-security scan . --model gpt-5.6-terra --effort high +npx @openai/codex-security scan . --mode deep --workers 2 --subagents 0 --stop-after-no-new 3 --max-discovery-runs 10 ``` For CI, set `OPENAI_API_KEY` or `CODEX_API_KEY` instead of signing in. Environment API keys are @@ -58,6 +59,13 @@ import { CodexSecurity } from "@openai/codex-security"; const security = new CodexSecurity(); const result = await security.run("."); +await security.run(".", { + mode: "deep", + workers: 2, + subagents: 0, + stopAfterNoNew: 3, + maxDiscoveryRuns: 10, +}); console.log(result.reportPath); await security.close(); diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 21f02ce..12aa405 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -183,6 +183,7 @@ npx @openai/codex-security scan /path/to/repository --output-dir /path/outside/r npx @openai/codex-security scan /path/to/repository --dry-run npx @openai/codex-security scan /path/to/repository --fail-on-severity high npx @openai/codex-security scan /path/to/repository --max-cost 5 +npx @openai/codex-security scan /path/to/repository --mode deep --workers 2 --subagents 0 --stop-after-no-new 3 --max-discovery-runs 10 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 @@ -224,6 +225,37 @@ to Repeat `--knowledge-base PATH` for multiple files or directories. Directories are searched recursively for Markdown, text, PDF, and Word (`.docx`) files. +### Configure deep scans + +For `scan --mode deep`, `--workers` limits concurrent discovery workers, +`--subagents` controls each worker's subagents, `--stop-after-no-new` stops after +that many runs find no new issues, and `--max-discovery-runs` limits total runs. +These options are also available on SDK scans: + +```ts +await security.run("/path/to/repository", { + mode: "deep", + workers: 2, + subagents: 0, + stopAfterNoNew: 3, + maxDiscoveryRuns: 10, +}); +``` + +Set defaults in `~/.codex/codex-security/config.toml`, or under `$CODEX_HOME` +when it is configured. Explicit CLI and SDK settings override these defaults: + +```toml +[deep_scan] +workers = 2 +subagents = 0 +stop_after_no_new = 3 +max_discovery_runs = 10 +``` + +`scan --workers` controls discovery workers within one deep scan; +`bulk-scan --workers` controls how many repositories are scanned concurrently. + On macOS/Linux, an existing output directory must be private to the current user (`chmod 700`). diff --git a/sdk/typescript/_bundled_plugin/scripts/deep_scan_workbench.py b/sdk/typescript/_bundled_plugin/scripts/deep_scan_workbench.py index e9b9d84..91df5ec 100644 --- a/sdk/typescript/_bundled_plugin/scripts/deep_scan_workbench.py +++ b/sdk/typescript/_bundled_plugin/scripts/deep_scan_workbench.py @@ -531,6 +531,30 @@ def begin_deep_scan_for_scan( args: argparse.Namespace, ) -> dict[str, Any]: scan_id = require_uuid(scan_id, "scan-id") + candidate = require_scan(connection, scan_id) + workspace = require_workspace(connection, candidate["workspace_id"]) + if ( + candidate["mode"] == "deep" + and candidate["status"] == "running" + and candidate["recipe_json"] is not None + and candidate["handoff_status"] == "delivered" + and candidate["deep_scan_owner_thread_id"] is None + and workspace["thread_id"] is None + ): + timestamp = now() + with connection: + claimed_workspace = connection.execute( + "UPDATE workspaces SET thread_id = ?, updated_at = ? " + "WHERE id = ? AND thread_id IS NULL", + (thread_id, timestamp, workspace["id"]), + ) + claimed_scan = connection.execute( + "UPDATE scans SET deep_scan_owner_thread_id = ?, updated_at = ? " + "WHERE id = ? AND deep_scan_owner_thread_id IS NULL", + (thread_id, timestamp, scan_id), + ) + if claimed_workspace.rowcount != 1 or claimed_scan.rowcount != 1: + raise SystemExit("A scan can only be orchestrated from its owning Codex thread.") scan, _ = require_owned_scan(connection, scan_id, thread_id) require_current_continuation( scan, diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 32269c0..888431f 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -1,10 +1,23 @@ /// -import { chmod, lstat, mkdir, realpath, rm, writeFile } from "node:fs/promises"; +import { + chmod, + lstat, + mkdir, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { homedir, tmpdir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, sep } from "node:path"; import { Codex, type CodexOptions } from "@openai/codex-sdk"; +import { + parse as parseToml, + stringify as stringifyToml, + type TomlTable, +} from "smol-toml"; import { accountStatus, CodexLoginHandle, @@ -119,7 +132,14 @@ interface PreparedRuntime { effectiveConfig?: JsonObject; } -export interface ScanOptions { +export interface DeepScanOptions { + workers?: number; + subagents?: number; + stopAfterNoNew?: number; + maxDiscoveryRuns?: number; +} + +export interface ScanOptions extends DeepScanOptions { auth?: ScanAuthMode; target?: ScanTarget; mode?: ScanMode; @@ -174,7 +194,7 @@ type ScanObserverName = | "onWorkerStatus" | "onWarning"; -export interface ScanPreflight { +export interface ScanPreflight extends DeepScanOptions { repository: string; target: NormalizedTarget; mode: ScanMode; @@ -219,6 +239,12 @@ const DEFAULT_DEPENDENCIES: ClientDependencies = { }; const SCAN_PERMISSION_PROFILE = "codex_security_scan"; +const DEEP_SCAN_SETTINGS = [ + ["workers", "workers", 1], + ["subagents", "subagents", 0], + ["stopAfterNoNew", "stop_after_no_new", 1], + ["maxDiscoveryRuns", "max_discovery_runs", 1], +] as const; export class CodexSecurity { public readonly config: Readonly; @@ -282,6 +308,7 @@ export class CodexSecurity { repository: inputs.repository, target: inputs.target, mode: inputs.mode, + ...deepScanOptions(options), ...(options.knowledgeBasePaths?.length ? { knowledgeBasePaths: options.knowledgeBasePaths } : {}), @@ -409,6 +436,14 @@ export class CodexSecurity { } const runtimeHome = await realpath(runtime.codexHome); requireOutputOutsideRepository(protectedRoot, runtimeHome, "runtime"); + if (mode === "deep") { + await prepareDeepScanConfig( + runtimeHome, + this.#dependencies.environment, + options, + signal, + ); + } if ( options.expectedPluginVersion !== undefined && runtime.plugin.version !== options.expectedPluginVersion @@ -579,6 +614,7 @@ export class CodexSecurity { options.failureSeverity, knowledgeBase?.sources, options.maxCostUsd, + deepScanOptions(options), ); const workbenchOptions: WorkbenchCommandOptions = { python, @@ -1200,6 +1236,7 @@ export class CodexSecurity { options: ScanOptions, signal?: AbortSignal, ): Promise { + deepScanOptions(options); if ( options.maxCostUsd !== undefined && (!Number.isFinite(options.maxCostUsd) || options.maxCostUsd <= 0) @@ -1340,6 +1377,71 @@ export class CodexSecurity { } } +function deepScanOptions(options: ScanOptions): DeepScanOptions { + const selected: DeepScanOptions = {}; + for (const [name, , minimum] of DEEP_SCAN_SETTINGS) { + const value = options[name]; + if (value === undefined) continue; + if ((options.mode ?? "standard") !== "deep") { + throw new CodexSecurityError("Deep scan settings require deep mode."); + } + if (!Number.isSafeInteger(value) || value < minimum) { + throw new CodexSecurityError( + `Deep scan ${name} must be ${minimum === 0 ? "a non-negative" : "a positive"} integer.`, + ); + } + selected[name] = value; + } + return selected; +} + +async function prepareDeepScanConfig( + codexHome: string, + environment: ProcessEnvironment, + options: DeepScanOptions, + signal: AbortSignal, +): Promise { + const ambientHome = + environmentValue(environment, "CODEX_HOME") ?? join(homedir(), ".codex"); + const source = join(ambientHome, "codex-security", "config.toml"); + let configured: TomlTable = {}; + try { + configured = parseToml( + await readFile(source, { encoding: "utf8", signal }), + ); + } catch (error) { + if (!isRecord(error) || error["code"] !== "ENOENT") { + throw new CodexSecurityError( + `Cannot read Codex Security configuration at ${source}.`, + { cause: error }, + ); + } + } + const existing = configured["deep_scan"]; + if (existing !== undefined && !isRecord(existing)) { + throw new CodexSecurityError( + `Codex Security configuration [deep_scan] at ${source} must be a TOML table.`, + ); + } + const overrides: TomlTable = {}; + for (const [name, key] of DEEP_SCAN_SETTINGS) { + const value = options[name]; + if (value !== undefined) overrides[key] = value; + } + if (existing === undefined && Object.keys(overrides).length === 0) return; + const destination = join(codexHome, "codex-security", "config.toml"); + if (destination === source && Object.keys(overrides).length === 0) return; + await mkdir(dirname(destination), { recursive: true, mode: 0o700 }); + await writeFile( + destination, + stringifyToml({ + ...configured, + deep_scan: { ...existing, ...overrides }, + }), + { mode: 0o600, signal }, + ); +} + export async function initialCredentialsAvailable( environment: ProcessEnvironment, ambientHome: string, @@ -1552,6 +1654,11 @@ async function scanPrompt( return [ `Use the installed $codex-security:${skillName} skill at "$CODEX_SECURITY_PLUGIN_ROOT/skills/${skillName}/SKILL.md".`, "Run this Codex Security scan non-interactively.", + ...(mode === "deep" + ? [ + 'The SDK has already registered this scan. Call start_codex_security_deep_scan with { scanId: "$CODEX_SECURITY_SCAN_ID" }; never pass targetPath or create another scan.', + ] + : []), ...(skillName === "deep-security-scan" ? [] : [ @@ -1618,6 +1725,7 @@ function scanRecipe( failOnSeverity?: SeverityLevel, knowledgeBasePaths?: string[], maxCostUsd?: number, + deepScan?: DeepScanOptions, ): JsonObject { return { repository, @@ -1636,6 +1744,9 @@ function scanRecipe( ...(failOnSeverity === undefined ? {} : { failOnSeverity }), ...(knowledgeBasePaths === undefined ? {} : { knowledgeBasePaths }), ...(maxCostUsd === undefined ? {} : { maxCostUsd }), + ...(deepScan === undefined || Object.keys(deepScan).length === 0 + ? {} + : { deepScan: { ...deepScan } }), }; } diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 30dc6f4..4ff4f02 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -32,6 +32,7 @@ import { classifyConnectionFailure, CodexSecurity, scanAuthentication, + type DeepScanOptions, type ScanAuthMode, type ScanAuthentication, type ScanOptions, @@ -173,6 +174,9 @@ const VALUE_OPTIONS = new Set([ "--fail-on-severity", "--max-cost", "--workers", + "--subagents", + "--stop-after-no-new", + "--max-discovery-runs", "--max-attempts", "--export-format", "--output", @@ -200,7 +204,34 @@ function effortOption() { ); } -interface ScanArguments { +const DEEP_SCAN_OPTION_SCHEMAS = { + workers: z + .number() + .int() + .positive() + .optional() + .describe("Maximum concurrent deep-scan discovery workers."), + subagents: z + .number() + .int() + .nonnegative() + .optional() + .describe("Subagents available to each deep-scan worker."), + stopAfterNoNew: z + .number() + .int() + .positive() + .optional() + .describe("Stop after this many runs find no new issues."), + maxDiscoveryRuns: z + .number() + .int() + .positive() + .optional() + .describe("Maximum deep-scan discovery runs."), +}; + +interface ScanArguments extends DeepScanOptions { auth?: ScanAuthMode; repository?: string; paths: string[]; @@ -998,6 +1029,7 @@ export async function main( .enum(["standard", "deep"]) .default("standard") .describe("Scan mode; deep supports repository and path targets."), + ...DEEP_SCAN_OPTION_SCHEMAS, model: optionValue("--model") .optional() .describe( @@ -1062,6 +1094,15 @@ export async function main( (options) => !options.archiveExisting || options.outputDir !== undefined, { message: "--archive-existing requires --output-dir." }, + ) + .refine( + (options) => + options.mode === "deep" || + (options.workers === undefined && + options.subagents === undefined && + options.stopAfterNoNew === undefined && + options.maxDiscoveryRuns === undefined), + { message: "Deep scan settings require --mode deep." }, ), examples: [ { args: { repository: "." } }, @@ -1101,6 +1142,10 @@ export async function main( head: options.head, base: options.base, mode: options.mode, + workers: options.workers, + subagents: options.subagents, + stopAfterNoNew: options.stopAfterNoNew, + maxDiscoveryRuns: options.maxDiscoveryRuns, model: options.model, effort: options.effort, outputDir: options.outputDir, @@ -1835,6 +1880,24 @@ function scanArgumentsFromRecipe( "The saved scan recipe contains an invalid cost limit.", ); } + const deepScan = z + .object(DEEP_SCAN_OPTION_SCHEMAS) + .optional() + .safeParse(recipe["deepScan"]); + if (!deepScan.success) { + throw new CodexSecurityError( + "The saved scan recipe contains invalid deep scan settings.", + ); + } + if ( + mode !== "deep" && + deepScan.data !== undefined && + Object.keys(deepScan.data).length > 0 + ) { + throw new CodexSecurityError( + "The saved scan recipe contains deep scan settings for a standard scan.", + ); + } return { repository, paths, @@ -1844,6 +1907,7 @@ function scanArgumentsFromRecipe( head: kind === "refs" ? head ?? "HEAD" : undefined, base: kind === "working_tree" ? reference : undefined, mode, + ...deepScan.data, archiveExisting: false, codex: [], codexOverrides: config, @@ -2615,6 +2679,10 @@ async function runScan( target, knowledgeBasePaths: arguments_.knowledgeBasePaths, mode: arguments_.mode, + workers: arguments_.workers, + subagents: arguments_.subagents, + stopAfterNoNew: arguments_.stopAfterNoNew, + maxDiscoveryRuns: arguments_.maxDiscoveryRuns, outputDir: arguments_.outputDir, archiveExisting: arguments_.archiveExisting, parentScanId: arguments_.parentScanId, diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index dc1c64d..2ef1c60 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -3,6 +3,7 @@ export { estimateScanCost } from "./cost.js"; export type { ScanCost } from "./cost.js"; export type { CodexSecurityMetadata, + DeepScanOptions, ScanAuthMode, ScanAuthentication, ScanOptions, diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 0ce7f5b..6f11f48 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -729,6 +729,55 @@ describe("CodexSecurity orchestration", () => { await client.close(); }); + test("validates deep scan settings before initializing the runtime", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + await mkdir(repository); + let runtimeStarted = false; + const client = new TestClient( + {}, + { + environment: {}, + prepareRuntime: async () => { + runtimeStarted = true; + throw new Error("runtime should not initialize"); + }, + }, + ); + + await expect( + client.preflight(repository, { + mode: "deep", + workers: 2, + subagents: 0, + stopAfterNoNew: 3, + maxDiscoveryRuns: 10, + }), + ).resolves.toMatchObject({ + mode: "deep", + workers: 2, + subagents: 0, + stopAfterNoNew: 3, + maxDiscoveryRuns: 10, + }); + await expect(client.preflight(repository, { workers: 1 })).rejects.toThrow( + "Deep scan settings require deep mode", + ); + for (const invalid of [ + { workers: 0 }, + { workers: 1.5 }, + { subagents: -1 }, + { stopAfterNoNew: 0 }, + { maxDiscoveryRuns: Number.POSITIVE_INFINITY }, + ]) { + await expect( + client.preflight(repository, { mode: "deep", ...invalid }), + ).rejects.toThrow("integer"); + } + expect(runtimeStarted).toBe(false); + await client.close(); + }); + test("validates knowledge-base documents before initializing the runtime", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); @@ -1697,6 +1746,85 @@ describe("CodexSecurity orchestration", () => { await client.close(); }); + test("applies deep scan overrides over the user's existing settings", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const ambientHome = join(root, "ambient-home"); + const codexHome = join(root, "runtime-home"); + const scanDir = join(root, "scan"); + await mkdir(repository); + await mkdir(join(ambientHome, "codex-security"), { recursive: true }); + await mkdir(codexHome); + await mkdir(scanDir, { mode: 0o700 }); + await writeFile( + join(ambientHome, "codex-security", "config.toml"), + [ + "[deep_scan]", + "workers = 5", + "subagents = 2", + "stop_after_no_new = 7", + "max_discovery_runs = 60", + "[other]", + "enabled = true", + "", + ].join("\n"), + ); + let recipe: Record | undefined; + const client = new TestClient( + {}, + { + environment: { CODEX_HOME: ambientHome }, + prepareRuntime: async () => preparedRuntime(codexHome), + resolvePluginPython: async () => "/managed/python", + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => "deadbeef", + runWorkbench: async (_options: unknown, args: readonly string[]) => { + if (args[0] !== "register-cli-scan") { + return { + scanId: "scan_example_001", + targetId: "target_sha256_example", + falsePositives: [], + }; + } + recipe = JSON.parse(args[args.indexOf("--recipe-json") + 1]!); + return mockScanRegistration(args); + }, + createCodex: () => ({ + startThread: () => ({ + id: null, + async runStreamed() { + throw new Error("deep scan settings captured"); + }, + }), + }), + }, + ); + + await expect( + client.run(repository, { + mode: "deep", + workers: 2, + subagents: 0, + maxDiscoveryRuns: 10, + }), + ).rejects.toThrow("deep scan settings captured"); + const configuration = await readFile( + join(codexHome, "codex-security", "config.toml"), + "utf8", + ); + expect(configuration).toContain("workers = 2"); + expect(configuration).toContain("subagents = 0"); + expect(configuration).toContain("stop_after_no_new = 7"); + expect(configuration).toContain("max_discovery_runs = 10"); + expect(configuration).toContain("[other]"); + expect(configuration).toContain("enabled = true"); + expect(recipe).toMatchObject({ + mode: "deep", + deepScan: { workers: 2, subagents: 0, maxDiscoveryRuns: 10 }, + }); + await client.close(); + }); + test("rejects a scan registration without an authoritative target contract", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); @@ -3207,6 +3335,9 @@ describe("CodexSecurity orchestration", () => { "prompt captured", ); expect(prompt).toContain("$codex-security:deep-security-scan"); + expect(prompt).toContain( + 'start_codex_security_deep_scan with { scanId: "$CODEX_SECURITY_SCAN_ID" }', + ); expect(prompt).not.toContain( "This exhaustive scan authorizes the delegated-worker phases", ); diff --git a/sdk/typescript/tests-ts/cli-workbench.test.ts b/sdk/typescript/tests-ts/cli-workbench.test.ts index 90156a4..aea574b 100644 --- a/sdk/typescript/tests-ts/cli-workbench.test.ts +++ b/sdk/typescript/tests-ts/cli-workbench.test.ts @@ -548,6 +548,12 @@ describe("CLI workbench", () => { pluginVersion: "1.2.3", failOnSeverity: "high", knowledgeBasePaths: ["/original/security.md"], + deepScan: { + workers: 2, + subagents: 0, + stopAfterNoNew: 3, + maxDiscoveryRuns: 10, + }, config: savedConfig, }, }), @@ -563,6 +569,10 @@ describe("CLI workbench", () => { expectedPluginVersion: "1.2.3", failureSeverity: "high", knowledgeBasePaths: ["/original/security.md"], + workers: 2, + subagents: 0, + stopAfterNoNew: 3, + maxDiscoveryRuns: 10, }); const references: Array<[JsonObject, ReturnType]> = diff --git a/sdk/typescript/tests-ts/cli.test.ts b/sdk/typescript/tests-ts/cli.test.ts index d16b6db..d716861 100644 --- a/sdk/typescript/tests-ts/cli.test.ts +++ b/sdk/typescript/tests-ts/cli.test.ts @@ -108,6 +108,10 @@ describe("CLI", () => { properties: { path: { type: "array" }, mode: { enum: ["standard", "deep"] }, + workers: { type: "integer" }, + subagents: { type: "integer" }, + stopAfterNoNew: { type: "integer" }, + maxDiscoveryRuns: { type: "integer" }, model: { type: "string" }, effort: { enum: ["minimal", "low", "medium", "high", "xhigh"] }, failOnSeverity: { enum: ["critical", "high", "medium", "low"] }, @@ -1462,6 +1466,10 @@ describe("CLI", () => { expect(help.text()).toContain("Usage: codex-security scan [repository]"); expect(help.text()).toContain("--path "); expect(help.text()).toContain("--max-cost "); + expect(help.text()).toContain("--workers "); + expect(help.text()).toContain("--subagents "); + expect(help.text()).toContain("--stop-after-no-new "); + expect(help.text()).toContain("--max-discovery-runs "); expect(help.text()).toContain("--model "); expect(help.text()).toContain( `OpenAI model to use (default: ${DEFAULT_SCAN_MODEL_CONFIGURATION.model}).`, @@ -1592,6 +1600,14 @@ describe("CLI", () => { "--knowledge-base=/shared/threat-models", "--mode", "deep", + "--workers", + "2", + "--subagents", + "0", + "--stop-after-no-new", + "3", + "--max-discovery-runs", + "10", "--plugin-path", "plugin.zip", "--python=/managed/python", @@ -1612,6 +1628,10 @@ describe("CLI", () => { expect(pathOptions).toMatchObject({ target: ["src", "--fixtures"], knowledgeBasePaths: ["/shared/architecture.pdf", "/shared/threat-models"], + workers: 2, + subagents: 0, + stopAfterNoNew: 3, + maxDiscoveryRuns: 10, }); expect(pathConfig).toMatchObject({ pluginPath: "plugin.zip", @@ -1725,6 +1745,26 @@ describe("CLI", () => { [["scan", ".", "--base", "HEAD"], "--base requires --working-tree"], [["scan", ".", "--archive-existing"], "requires --output-dir"], [["scan", ".", "--max-cost=0"], "expected number to be >0"], + [ + ["scan", ".", "--workers", "2"], + "Deep scan settings require --mode deep", + ], + [ + ["scan", ".", "--mode", "deep", "--workers", "0"], + "expected number to be >0", + ], + [ + ["scan", ".", "--mode", "deep", "--subagents", "-1"], + "expected number to be >=0", + ], + [ + ["scan", ".", "--mode", "deep", "--stop-after-no-new", "0"], + "expected number to be >0", + ], + [ + ["scan", ".", "--mode", "deep", "--max-discovery-runs", "0"], + "expected number to be >0", + ], [["scan", ".", "--path="], "--path must not be empty"], [["scan", ".", "--model="], "--model must not be empty"], [