From 40f4284669fac0313a401a892ec0cf83f86be6e6 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 28 Aug 2026 23:16:23 -0700 Subject: [PATCH 01/12] feat(cli): add YAML and JSON project configuration --- README.md | 7 + docs/examples/codex-security.json | 12 + docs/examples/codex-security.yaml | 10 + docs/project-configuration-qa.md | 90 +++ docs/project-configuration.md | 187 +++++ .../cli-and-project-configuration-review.md | 211 ++++++ .../cli-and-project-configuration.md | 486 +++++++++++++ docs/proposals/configuration-schema.md | 197 ++++++ docs/proposals/examples/codex-security.json | 13 + docs/proposals/examples/codex-security.yaml | 17 + plugins/codex-security/plugin-files.json | 1 + .../scripts/deep_scan_config.py | 18 +- .../scripts/deep_scan_defaults.json | 8 + sdk/typescript/README.md | 114 +++- sdk/typescript/package.json | 12 +- sdk/typescript/pnpm-lock.yaml | 6 + .../schemas/project-config.schema.json | 211 ++++++ sdk/typescript/scripts/check-package.mjs | 8 + .../scripts/generate-deep-defaults.mjs | 24 + .../generate-project-config-schema.mjs | 10 + sdk/typescript/src/api.ts | 173 ++--- sdk/typescript/src/cli.ts | 312 +++++---- sdk/typescript/src/config.ts | 9 + sdk/typescript/src/deep-config.ts | 133 ++++ sdk/typescript/src/deep-scan-defaults.ts | 9 + sdk/typescript/src/project-config-schema.ts | 132 ++++ sdk/typescript/src/project-config.ts | 277 ++++++++ sdk/typescript/src/scan-settings.ts | 50 ++ sdk/typescript/tests-ts/api.test.ts | 47 +- .../tests-ts/cli-project-config.test.ts | 637 ++++++++++++++++++ sdk/typescript/tests-ts/deep-config.test.ts | 134 ++++ .../tests-ts/project-config.test.ts | 325 +++++++++ sdk/typescript/tests-ts/skeleton.test.ts | 4 +- 33 files changed, 3586 insertions(+), 298 deletions(-) create mode 100644 docs/examples/codex-security.json create mode 100644 docs/examples/codex-security.yaml create mode 100644 docs/project-configuration-qa.md create mode 100644 docs/project-configuration.md create mode 100644 docs/proposals/cli-and-project-configuration-review.md create mode 100644 docs/proposals/cli-and-project-configuration.md create mode 100644 docs/proposals/configuration-schema.md create mode 100644 docs/proposals/examples/codex-security.json create mode 100644 docs/proposals/examples/codex-security.yaml create mode 100644 plugins/codex-security/scripts/deep_scan_defaults.json create mode 100644 sdk/typescript/schemas/project-config.schema.json create mode 100644 sdk/typescript/scripts/generate-deep-defaults.mjs create mode 100644 sdk/typescript/scripts/generate-project-config-schema.mjs create mode 100644 sdk/typescript/src/deep-config.ts create mode 100644 sdk/typescript/src/deep-scan-defaults.ts create mode 100644 sdk/typescript/src/project-config-schema.ts create mode 100644 sdk/typescript/src/project-config.ts create mode 100644 sdk/typescript/src/scan-settings.ts create mode 100644 sdk/typescript/tests-ts/cli-project-config.test.ts create mode 100644 sdk/typescript/tests-ts/deep-config.test.ts create mode 100644 sdk/typescript/tests-ts/project-config.test.ts diff --git a/README.md b/README.md index 15f44770f..bf52431d4 100644 --- a/README.md +++ b/README.md @@ -89,3 +89,10 @@ codex-security scan . --provider fireworks --model accounts/fireworks/models/qwe ## Documentation **๐Ÿ‘‰๐Ÿ‘‰ See the [Codex Security documentation](https://learn.chatgpt.com/docs/security/cli)** for full documentation. + +The [project configuration prototype](docs/project-configuration.md) supports explicit +YAML/JSON files in this working tree; it is not yet a released CLI feature. + +For the design and remaining proposals, see the [project configuration and CLI proposal](docs/proposals/cli-and-project-configuration.md), +its [Promptfoo implementation review](docs/proposals/cli-and-project-configuration-review.md), +and the [JSON Schema design](docs/proposals/configuration-schema.md). diff --git a/docs/examples/codex-security.json b/docs/examples/codex-security.json new file mode 100644 index 000000000..4caf41f40 --- /dev/null +++ b/docs/examples/codex-security.json @@ -0,0 +1,12 @@ +{ + "$schema": "../../sdk/typescript/schemas/project-config.schema.json", + "scan": { + "mode": "standard", + "scope": { "paths": ["sdk/typescript/src"] } + }, + "codex": { + "model": "gpt-5.6-sol", + "model_reasoning_effort": "xhigh" + }, + "policy": { "failOnSeverity": "high" } +} diff --git a/docs/examples/codex-security.yaml b/docs/examples/codex-security.yaml new file mode 100644 index 000000000..b6a880beb --- /dev/null +++ b/docs/examples/codex-security.yaml @@ -0,0 +1,10 @@ +# yaml-language-server: $schema=../../sdk/typescript/schemas/project-config.schema.json +scan: + mode: standard + scope: + paths: [sdk/typescript/src] +codex: + model: gpt-5.6-sol + model_reasoning_effort: xhigh +policy: + failOnSeverity: high diff --git a/docs/project-configuration-qa.md b/docs/project-configuration-qa.md new file mode 100644 index 000000000..20d69f564 --- /dev/null +++ b/docs/project-configuration-qa.md @@ -0,0 +1,90 @@ +# Project configuration prototype: QA + +These checks cover the local, unreleased implementation described in the +[prototype guide](project-configuration.md). They do not certify a released +package or a live scan against a model. + +| Check | Result | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Full Bun suite | 2,136 passed, 36 skipped, zero failures across 112 files in both runs (seeds `12345` and `2460537364`). | +| Focused SDK regression | 243 passed, 3 skipped across five files. | +| New configuration coverage | 81 cases across the input/schema, CLI, and deep-settings tests. | +| Real CLI invocations | 63 passed on Node 22.15.1, 24.15.0, and 26.0.0. | +| Packaged SDK and CLI | Passed installed public-import and strict NodeNext type checks, CLI startup, credential locking, bundled Codex, MCP initialization, and nested-worker checks. | +| Package contents | 412 archive entries, including the project schema and 123 bundled plugin files. | +| Editor language services | YAML Language Server 1.21.0 and JSON Language Service 4.1.8 accepted the examples, reported unknown keys, and supplied completions without network schema requests. | +| Zod / JSON Schema agreement | 26 shared input cases, with no coercion, default insertion, or unknown-key stripping. | +| Python checks | 64 capability-profile and source-compatibility tests passed. | +| MCP suite | All 22 test scripts completed successfully. | +| Static checks | TypeScript/MCP type checks, SDK formatting, Ruff lint/format, and plugin source compatibility passed. | + +The CLI checks used isolated settings and synthetic fixtures. They exercised file +and CLI precedence, native profiles, context and output paths, scope replacement, +inactive deep settings, all six resolved deep settings, and errors for missing or +invalid selected files. Help and CLI schema inspection succeeded without loading +a selected missing file. Both documented examples and empty YAML/JSON configurations ran successfully. No scan state +or output directories were created by these dry runs. + +QA found and fixed several integration problems: + +- An absent working-tree boolean incorrectly triggered the scope-conflict check. + Explicit presence now controls scope selection. +- Inferred SDK types initially pulled CLI-only declarations into an installed + consumer. Shared schemas now import Zod directly; the strict installed-consumer + check passes without suppressing declaration errors. +- A schema URN caused editor services to resolve local references incorrectly. + The generated schema now uses its file location as its identity, and + its relative references resolve offline. +- The legacy TOML resolver needed to reject a date where a deep-settings table + was expected. Its existing environment-path behavior is retained, and runtime + preparation uses the resolved settings snapshot. +- Zod's cloned output discarded reserved native keys and accepted a reserved + unknown key in strict wrapper sections. File loading now uses the generated + schema with the SDK's existing Ajv validator, preserving native input for the + existing override checks. Regression tests load JSON and YAML files and reject + reserved unknown wrapper keys. + +Earlier full-suite attempts exposed an outdated build-command assertion and an +unused type import; both were corrected. Other failures required using a canonical +temporary directory on macOS and allowing the existing process-inspection test to +read process state. One intermediate run was stopped after existing process and +SQLite fixtures timed out. The isolated credential-lock test and the 44 +publication/target tests subsequently passed without changes to those fixtures. +The full suite passed again after removal of the project version field, both with seed `12345` and in a randomized order. + +The local environment used macOS arm64, Bun 1.3.14, Python 3.12.12, and Ruff +0.16.1. Python tests ran with unrelated environment-installed pytest plugins +disabled. The final full runs did not overlap package/MCP checks and prevented +idle sleep only for the duration of each command. + +To reproduce the repository checks with the required tool versions installed, +run from the SDK directory: + +```sh +pnpm run test --seed 12345 +pnpm run types +pnpm run format +pnpm run test +pnpm run test:mcp +mkdir -p /tmp/codex-security-package +pnpm pack --pack-destination /tmp/codex-security-package +pnpm run check:package /tmp/codex-security-package/openai-codex-security-0.1.23.tgz +``` + +On macOS, use a canonical temporary directory such as `TMPDIR=/private/tmp` for +the existing path-sensitive fixtures. The process-group test also needs local +process-inspection access. From the repository root: + +```sh +PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python -m pytest -q plugins/codex-security/tests/test_capability_profiles.py .github/scripts/test_check_plugin_source_compatibility.py +python -m ruff check --config plugins/codex-security/pyproject.toml .github/scripts/check_plugin_source_compatibility.py .github/scripts/test_check_plugin_source_compatibility.py plugins/codex-security +python -m ruff format --check --config plugins/codex-security/pyproject.toml .github/scripts/check_plugin_source_compatibility.py .github/scripts/test_check_plugin_source_compatibility.py plugins/codex-security +python .github/scripts/check_plugin_source_compatibility.py +``` + +No live inference, credential verification, Linux/Windows execution, or release +publication was performed. Editor testing used the actual language services, not +a graphical editor session. Full native Codex schema completion, automatic file +discovery, batch adoption, and complete input replay remain outside this +prototype. Existing conditional tests remain skipped where their conditions do +not apply. diff --git a/docs/project-configuration.md b/docs/project-configuration.md new file mode 100644 index 000000000..336aa9a66 --- /dev/null +++ b/docs/project-configuration.md @@ -0,0 +1,187 @@ +# Project configuration prototype + +This working tree implements the first increment of the +[configuration proposal](proposals/cli-and-project-configuration.md). It is not a +released CLI feature. Build the local package before trying it: + +```sh +cd sdk/typescript +pnpm run build:plugin +pnpm run build +cd ../.. +node sdk/typescript/bin/codex-security.mjs scan . -c docs/examples/codex-security.yaml --dry-run --json +``` + +The [YAML example](examples/codex-security.yaml) and equivalent +[JSON example](examples/codex-security.json) select this repository's TypeScript +source. A dry run checks local inputs; it does not start Codex, verify credentials, +or establish that a model is available. Removing `--dry-run` starts a scan and may +incur model charges. + +The [QA report](project-configuration-qa.md) records the checks, fixes, and limits +of the local prototype. + +## Select a file + +`scan [repository] -c FILE`, also spelled `--config FILE`, loads one `.yaml`, `.yml`, +or `.json` file. Without that option, no project file is loaded, even when a +`codex-security.yaml` exists. Other commands and direct SDK `run()` calls do not +discover project files. There are no new initialization, discovery, or inspection +commands in this increment. + +The repository comes from the positional argument or the invocation directory. +Moving the configuration file does not change the target. The initial format +does not accept `repository` or `repositories` keys. + +For an ordinary project with a `src` directory, a minimal configuration is: + +```yaml +scan: + scope: + paths: [src] +codex: + model: gpt-5.6-sol + model_reasoning_effort: xhigh +policy: + failOnSeverity: high +``` + +All settings are optional; `{}` uses the existing defaults. The file configures +settings, not automatic actions: +patching, PR creation, publication, post-scan actions, and machine-specific plugin +or Python selection remain explicit CLI/SDK inputs. + +## Overrides and paths + +Built-in defaults and applicable legacy deep settings are followed by the project +file, then explicitly supplied CLI values. Parsing preserves absent values; schema +defaults are documentation hints. Lists are replaced, not concatenated. + +| Path or setting | Resolution | +| ----------------------------------------------------------------------------------- | ------------------------------- | +| Repository positional argument | Invocation directory | +| File `scan.scope.paths` | Selected repository | +| File `scan.knowledgeBase`, `instructionsFile`, `validationFile`, `output.directory` | Configuration file's directory | +| CLI context, prompt, and output paths | Invocation directory | +| Native values under `codex` | Existing native Codex semantics | + +For example, `--knowledge-base context.md` replaces the file's entire context list +and resolves `context.md` from the invocation directory. Existing regular-file, +protected-path, credential, and outside-worktree output checks still apply. + +A scope selector replaces the file's scope variant. `--diff HEAD` discards file +paths; `--path src` discards a configured diff. A dependent `--head` can refine a +file diff, and `--base` can refine a file working-tree scope. Contradictory explicit +scope selectors fail. The existing `--no-working-tree` disables a configured +working-tree scope; it does not clear a file's path or committed-diff scope. + +There is no general CLI reset for file context, severity policy, cost limit, or +scope. Edit the file, select a different file, or omit `-c`. An empty context list +is valid; `null` is not a wrapper reset operator. + +Native objects merge using the existing configuration code. Duplicate native +assignments within the CLI layer remain errors; overriding a file value is valid. +A selected native profile can still take precedence over root model/effort values, +including convenience flags. `--provider openai` retains its existing behavior; +it does not clear a file's native provider selection. This prototype does not +change those compatibility rules. + +## Deep settings and limits + +```yaml +scan: + mode: deep + deep: + workers: 4 + subagentsPerWorker: 3 + stopAfterNoNew: 4 + stopAfterConsecutiveErrors: 3 + maxDiscoveryRuns: 40 + maxTimeHours: 96 +limits: + maxCostUsdPerScan: 10 +``` + +The deep values above are the existing defaults, now shared with the Python +plugin. The cost limit is illustrative, not an estimate. Legacy user +`[deep_scan]` TOML remains supported, including `workers = "auto"`. The file and +CLI can override individual values. All six effective values are resolved before +runtime preparation, written to the runtime configuration, and recorded in new +recipes. A complete saved set does not depend on the current legacy file. + +A valid deep block can stay in a standard-mode file; it is inactive in the +standard scan. Explicit deep CLI options still require deep mode. Deep diff scans +and custom validation remain unsupported. Counts retain their existing bounds, +zero subagents is valid, and discovery time cannot exceed 96 hours. + +`maxCostUsdPerScan` has the same meaning as `--max-cost`: an estimated limit for one +scan attempt. In-flight work may exceed it. It does not cover a whole batch, +planning, matching, patching, or publication. `failOnSeverity` controls exit status +without removing findings from the result. + +## Inspect the effective invocation + +Use the existing `scan ... --dry-run --json`. Existing output fields remain, with +these additions: + +- Deep preflight reports all six effective deep values and `deepScanSources` + (`default`, `legacy`, or `override`). This also applies without `-c` and can + reject invalid applicable legacy settings earlier than before. +- With `-c`, `projectConfig.path` identifies the selected file and + `projectConfig.sources` identifies the origin of merged settings. Native entries + describe origins of native keys; native profile selection still determines the + effective model/effort shown at the top level. +- Selected instruction/validation file paths and the severity policy are included + when configured. Raw native configuration and credential values are not dumped. + +Help, version, and CLI schema inspection do not load project files. A selected +missing, malformed, or invalid file exits `2`. Scan exit codes remain `0` for +completion without a policy failure, `1` for the configured finding threshold, +and `2` for failed, invalid, incomplete, or interrupted scans. + +## JSON Schema and editors + +The generated [project schema](../sdk/typescript/schemas/project-config.schema.json) +comes from [one Zod input definition](../sdk/typescript/src/project-config-schema.ts) +and ships at `@openai/codex-security/schemas/project-config.schema.json`. +It is self-contained and uses Draft-07. The loader uses the SDK's existing Ajv +validator against that generated schema and retains the parsed input unchanged. +This avoids Zod's cloning behavior for reserved object keys. `pnpm build` +regenerates the schema; tests check the artifact, ordinary Zod/schema agreement, +and file validation at that reserved-key boundary. + +For a local package installation, a file at the project root can use: + +```yaml +# yaml-language-server: $schema=./node_modules/@openai/codex-security/schemas/project-config.schema.json +{} +``` + +JSON files can use a root `$schema` string with the same relative path. The CLI +treats it as editor metadata and uses the schema bundled with the installed +package; it never fetches a validator from the hint. No hosted schema URL is +required. + +The schema rejects unknown wrapper keys and invalid structural inputs without +coercion or inserted defaults. Checks requiring CLI overrides, files, Git, native +configuration, or runtime availability happen separately. Completion and typo +detection inside `codex` cover common model/provider fields only; other native +JSON settings retain existing checks. CLI `scan --schema --json` and result +artifact schemas remain separate contracts. + +## Saved reruns + +New recipes retain the resolved native configuration, scope, auth choice, finding +policy, cost limit, context paths, and all active deep settings. Reruns do not load +the project file again. Older partial deep recipes continue using applicable +legacy defaults for missing settings. + +Recipes do not snapshot source or context contents. Reruns use the current checkout +and current context files, subject to existing target checks. Additional scan +instructions are not retained: new recipes mark that requirement, and `scans +rerun` refuses to silently omit them. Start a new `scan -c FILE` or `scan +--scan-prompt-file FILE` to supply those instructions again. Custom validation +keeps its existing `scans rerun --validation-prompt-file FILE` requirement. + +Complete input replay, automatic discovery, SDK file-loading convenience APIs, +batch/component adoption, and full native-schema completion remain separate work. diff --git a/docs/proposals/cli-and-project-configuration-review.md b/docs/proposals/cli-and-project-configuration-review.md new file mode 100644 index 000000000..df870f813 --- /dev/null +++ b/docs/proposals/cli-and-project-configuration-review.md @@ -0,0 +1,211 @@ +# Review: CLI and project configuration proposal + +The project-file direction is useful, but the first draft combined too many +changes and treated Promptfoo's configuration behavior as more uniform than it +is. The first implementation should prove that one explicitly selected file +resolves to the right scan. Initialization, automatic discovery, new inspection +commands, broader CLI restructuring, and complete input snapshots can follow. + +This review informs the revised +[proposal](cli-and-project-configuration.md). It records the pre-prototype audit +and does not change Promptfoo. The later [prototype](../project-configuration.md) +implements the explicit-file increment. + +The follow-up [alignment and JSON Schema design](configuration-schema.md) adds a +concrete draft schema and examines its relationship to runtime input validation, +native Codex settings, command introspection, and existing result schemas. + +The implementation review used Promptfoo **0.121.19** at +[ce4c59d](https://github.com/promptfoo/promptfoo/tree/ce4c59d93f055c9dfbbb66d841f681909089ddf0) +and Codex Security **0.1.23** at `0474146`. The Promptfoo source files cited below +matched that commit. Verification included 14 direct loader/parser probes and nine +source-CLI invocations under Node 24.18.0, including two built-in `echo` evaluations +with exported JSON. State, logs, fixtures, and outputs were isolated in temporary +directories. No live model calls, red-team generation, code scans, or publication +were performed. These are observations about the inspected source snapshot, not a +claim about every released build or platform. + +The most consequential changes to the first draft are: + +| Priority | Weakness in the first draft | Revision | +| -------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| High | A broad option-metadata refactor, precedence repair, five new CLI entrypoints/options, and stronger replay were bundled together. | Start with `scan -c FILE`, its schema and resolver, and the existing `scan --dry-run`. Do not make unrelated command cleanup a prerequisite. | +| High | โ€œCLI overrides winโ€ also promised to flatten native profiles, while the compatibility section promised unchanged argument behavior. | Distinguish merging the same configuration key from native profile selection. Preserve existing native semantics initially; review profile/alias behavior changes separately. | +| High | The planned dry run and recipe were described as fully resolved before checking the separate deep-settings runtime path. | Resolve legacy deep settings once and pass the same effective values to inspection, execution, and recipe persistence. | +| High | A saved configuration was treated as a reproducible scan. | Record resolved settings without rereading project YAML on rerun. Treat prompt/context snapshots, source restoration, and runtime pinning as additional work. | +| Medium | Repository selection, discovery, and two different kinds of configuration bypass complicated a settings file. | Keep the initial repository selection in `scan [repository]`. Add discovery later; a future `--no-config` should skip project YAML only. | +| Medium | The override contract mentioned empty lists and false values without explaining what existing flags can express. | Document replacement and clearing separately. Do not invent a flag for every reset operation. | + +Promptfoo's documented workflow remains a useful product reference: a project +file contains reusable settings, and explicit CLI arguments override defaults. +That is the contract described in its +[configuration reference](https://www.promptfoo.dev/docs/configuration/reference/). +The implementation needs a more qualified reading. + +| Promptfoo path | What the implementation does | Lesson for this proposal | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| Evaluation | Startup loads a discovered config before registering Commander options. Execution later reads explicit files, combines them, resolves resources, and applies runtime options. | Preserve the distinction between absent arguments and parser-generated defaults. | +| Red-team run | Generates a `redteam.yaml`, then evaluates it through another adapter. The evaluation call explicitly supplies `cache: true` and `write: true`; generation and evaluation have their own option handling. | Shared configuration does not mean every orchestration phase has identical controls. | +| Code scanning | Uses a separate small YAML schema, loader, and CLI merge. With no config path, its loader returns defaults without discovering a file. | This narrower adapter is a closer starting point for a thin wrapper than the entire eval loader. | + +Sources: [CLI startup](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/main.ts#L54-L80), +[red-team orchestration](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/redteam/shared.ts#L95-L160), +and [code-scan loader](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/codeScan/config/loader.ts#L80-L138). +The red-team behavior was traced, not executed. + +**Precedence needs to survive both the parser and the schema.** The eval command +uses discovered values as Commander defaults for options such as cache and table +output. Its action then parses those options through a Zod schema. The inherited +delay schema supplies zero even when the user did not pass `--delay`. Later +nullish-coalescing expressions cannot tell those generated values from explicit +arguments. See [command registration](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/commands/eval.ts#L93-L150), +[command schema](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/types/index.ts#L93-L121), +[action schema](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/node/doEval.ts#L76-L90), +and [runtime selection](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/node/doEval.ts#L527-L558). + +The probes used a synthetic file containing: + +```yaml +prompts: ["{{value}}"] +providers: [echo] +tests: + - vars: + value: synthetic +sharing: false +commandLineOptions: + cache: false + table: false + delay: 17 + maxConcurrency: 2 +``` + +| Probe | Observed result | +| ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| Parse an explicit `eval -c FILE` with no runtime flags and no discovered config | Commander supplies `cache: true` and `table: true`; schema parsing adds `delay: 0`. The loaded file still contains false/false/17. | +| Discover false cache/table defaults, then select another file containing true values with `-c` | The resolved file is the explicit file, but the earlier false parser defaults survive. | +| Run the real source CLI with file table=false/delay=17, plus `--no-cache --no-write --no-share` | A table is printed. Exported runtime options omit delay and retain concurrency 2. | +| Add explicit `--no-table --delay 17` to that run | The table disappears; exported runtime options contain delay 17 and concurrency 1. Both echo evaluations succeed with no errors or model cost. | + +The cache observations above are parser/resolver probes; actual evaluations +explicitly disabled caching. This distinction matters when interpreting the +evidence. + +Existing [evaluate-option tests](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/test/commands/eval/evaluateOptions.test.ts#L83-L99) +call `doEval` directly with constructed option objects. They provide useful coverage +of runtime precedence, but that path does not exercise Commander defaults or the +action's schema parsing. Our acceptance checks need both resolver tests and a real +CLI invocation. Codex Security already has schema defaults for mode, auth, paths, +and other settings, so this is a concrete integration concern in +[its scan registration](../../sdk/typescript/src/cli.ts). + +**Loading must happen after command routing and explicit selection.** Promptfoo's +startup reads default configuration before parsing most commands. A malformed +`promptfooconfig.yaml` made `eval --help` fail and also prevented `validate config +-c VALID_FILE` from reaching the selected file. `code-scans run --help` succeeded +because that command family has an explicit +[default-loading exception](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/mainUtils.ts#L104-L132). + +The proposal should avoid loading a file merely to build help or register +defaults. First route the command; then select the one file it actually uses. +An explicit `-c` must not read the discoverable file first. Loading should not +initialize scan history, authentication, a provider, or a runtime. This also keeps +unrelated commands usable when project YAML is invalid. + +**Schema validation and execution preparation are different operations.** +Promptfoo's [reader](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/util/config/load.ts#L328-L414) +dereferences configuration references and renders environment templates before +validation. It normalizes `commandLineOptions` with a failing validation path, but other schema +errors can be warnings while the original object is returned. A string +`evaluateOptions.maxConcurrency: many` was returned by `readConfig`; the full +validation command subsequently rejected it. A misspelled option was discarded, +and `validate config` reported success. The two namespaces also have different +numeric constraints: negative concurrency was accepted under `evaluateOptions` +and rejected under `commandLineOptions`. These probes do not establish how an +evaluation with negative concurrency would behave. + +The [validation command](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/commands/validate.ts#L470-L511) +calls the full resolver, which +[loads providers](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/util/config/load.ts#L937-L960). +A local provider fixture wrote a marker from its constructor during validation; +its inference method was never called. This is an execution-boundary observation, +not a claim that loading a trusted local provider is itself a security defect. + +For Codex Security, reject invalid wrapper keys and types in the file parser, +merge explicit overrides, and then validate the active scan's combinations. +Keep runtime/model availability checks separate. Generate editor JSON Schema from +the serializable input schema and test the same examples against both validators. +Promptfoo's [schema generator](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/scripts/generateJsonSchema.ts#L88-L130) +requires special handling for transforms and runtime-only values; generating a +schema does not automatically prove complete runtime parity. + +**Multiple files need field-specific semantics and origin tracking.** +Promptfoo's [combiner](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/util/config/load.ts#L521-L753) +does not perform one generic deep merge. It combines tests and extensions, +deduplicates providers, merges option objects, treats sharing=false specially, and +rebases some prompt references. The later +[resolver](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/util/config/load.ts#L815-L849) +still uses the first config's directory as a shared base. + +In a two-directory probe, each config's prompt loaded from its own directory, but +a `defaultTest` reference supplied by the second file loaded from the first +directory. A single glob expanding to both config files also raised a path-type +error in this fixture. The relevant lesson is to keep the first Codex Security +format to one file, without `extends`, globs for configuration selection, or +overlays. Normalize each wrapper-owned path while its origin is known. Native +Codex path values need their existing native rules, not a recursive path rewrite. + +**Persisted settings are useful, but they are not an immutable dependency set.** +Promptfoo saves configuration and resolved +[runtime options](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/node/doEval.ts#L773-L786). +Resume prefers those persisted runtime values and +[re-resolves the saved config](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/node/doEval.ts#L103-L132). +In a direct probe of that resolution primitive, saving a config, editing its +referenced prompt, and resolving the saved config again loaded the edited prompt. +This was not a full pause/resume test. It establishes why a saved file reference +alone cannot promise input replay. + +Codex Security has a more immediate gap: its +[preflight and recipe construction](../../sdk/typescript/src/api.ts) select explicit +deep options, while runtime preparation separately reads the legacy TOML. The +Python [deep-settings resolver](../../plugins/codex-security/scripts/deep_scan_config.py) +also supports `stop_after_consecutive_errors`, which is absent from the current +TypeScript `DEEP_SCAN_SETTINGS` adapter. Adding YAML above these paths does not +make them agree automatically. + +The first implementation should resolve the active settings once, including all +deep defaults, and retain those values in the recipe. Runtime preparation must +consume them instead of rediscovering configuration. Full prompt/context capture, +source restoration, and runtime-version pinning should have a separate retention +and compatibility design. Do not advertise deterministic reruns before that work. + +**Native Codex configuration needs an explicit compatibility boundary.** Current +[model/profile resolution](../../sdk/typescript/src/config.ts) lets a selected +profile's model and effort take precedence over root values. Current CLI alias +handling also rejects duplicate `--model`/`--codex model` values, and an explicit +`--provider openai` does not populate the native provider key in the same way as +the other provider choices. Earlier CLI preflight probes reproduced both behaviors; +they did not run native inference. + +The first draft's proposed profile flattening changes those semantics. It should +be reviewed and tested as a compatibility change, not hidden inside YAML merging. +A native `codex` block can also contain paths and tool configuration; calling the +wrapper file declarative does not make every native setting inert. Start with +explicit selection and preserve existing protections. Do not implement another +native profile engine or claim exhaustive validation of future Codex options. + +The resulting implementation boundary is small enough to describe precisely: + +```text +route command and handle help + -> select one explicit project file + -> parse file fields and preserve explicitly supplied CLI fields + -> resolve wrapper-owned paths and merge settings + -> apply existing native semantics and validate the active scan + -> share resolved settings with dry run, execution, and saved recipe +``` + +No generic configuration framework or complete CLI metadata rewrite is required. +SDK callers should opt into project-file loading. A follow-up can add discovery +and initialization once their absence does not block normal CLI use, and another +can adapt existing batch/component orchestration. Keep command renaming and full +replay separate from those increments. diff --git a/docs/proposals/cli-and-project-configuration.md b/docs/proposals/cli-and-project-configuration.md new file mode 100644 index 000000000..14cd66db4 --- /dev/null +++ b/docs/proposals/cli-and-project-configuration.md @@ -0,0 +1,486 @@ +# Proposal: project configuration and a consistent CLI + +**Status: proposal with a local prototype of the first increment.** The +[prototype guide](../project-configuration.md) describes implemented behavior and +limits. Discovery, initialization, other new commands, and broader restructuring +remain proposals. The prototype has not been released. + +Codex Security should let a team save its normal scan settings in +`codex-security.yaml`, inspect those settings, and run a scan without reconstructing +a long command. CLI arguments should select a target or override individual +settings. The CLI and SDK should resolve those settings through shared code and +continue using the existing Codex runtime and security plugin. + +YAML is the primary authoring format; an explicit JSON file should represent the +same settings and use the same input schema. The +[Promptfoo alignment and JSON Schema design](configuration-schema.md) describes +the shared workflow, deliberate differences, and a +[generated schema](../../sdk/typescript/schemas/project-config.schema.json) for review. + +The first increment is an explicitly selected project file for `scan`, shared +configuration resolution, the existing dry run, and recording the resolved scan +settings. Automatic discovery, initialization, and new inspection commands can +follow. Batch/component adoption, command renaming, and complete replay snapshots +are separate increments. The [implementation review](cli-and-project-configuration-review.md) +explains this narrower scope and the Promptfoo behaviors behind it. + +Today, configuration is spread across several inputs: + +| Input | Current responsibility | Problem to address | +| ------------------------------------ | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `scan` arguments | Repository, scope, model, prompts, limits, output, and finding policy | Repeated commands are difficult to maintain and review. | +| `--codex KEY=VALUE` | Native Codex configuration overrides | Shell quoting and alias/profile precedence make common settings harder to inspect. | +| User `[deep_scan]` TOML | Deep discovery defaults | These settings need to participate in project configuration without becoming a competing source of defaults. | +| `bulk-scan` CSV and arguments | Repository inventory, revisions, retries, and outer concurrency | Supported settings differ from single scans. | +| `scan-components` JSON and arguments | Component planning, standard scans, and combined results | Its orchestration settings overlap with other commands but have different scope. | + +For example, `--workers` controls discovery workers on a deep scan, repositories on +a bulk scan, and components on a component scan. `--max-cost` is a per-scan or +per-attempt limit; it is not a total budget for a batch. Component planning and +matching are outside the component scan limit. The current +[CLI documentation](../../sdk/typescript/README.md#cli) and +[deep-scan configuration](../../sdk/typescript/README.md#configure-deep-scans) +remain the reference for supported behavior. + +Promptfoo provides a useful workflow precedent: a discoverable project file, +initialization, configuration validation, an editor schema, and CLI overrides. Its +[configuration reference](https://www.promptfoo.dev/docs/configuration/reference/) +and [CLI guide](https://www.promptfoo.dev/docs/usage/command-line/) describe that +workflow. Its eval, red-team, and code-scan implementations have different loading +and override paths, so they are not a single reference architecture. Codex Security +should give each setting one canonical location. It does not need both `commandLineOptions` and +`evaluateOptions`, executable configuration files, or Promptfoo's provider +abstraction. + +Keep three concepts separate throughout the design: + +| Concept | Choices | Existing constraints to preserve | +| ------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------- | +| Scope | Whole repository, paths, committed diff, working-tree diff | These select alternative targets. | +| Analysis mode | Standard or deep | Deep supports repository/path scopes and built-in validation. | +| Orchestration | One scan, components, repository batch | Components currently run standard scans. Batch retries apply per repository. | + +`bulk` and `components` should not become additional values of `scan.mode`. A list +of scoped paths should not silently turn into independent component scans. + +The first proposed workflow is: + +```sh +# Prototype commands using a locally built package; not yet released. +codex-security scan -c codex-security.yaml --dry-run --json +codex-security scan -c codex-security.yaml +codex-security scan . -c security/ci.yaml --model gpt-5.6-terra --effort high +``` + +The only initial new CLI option is `scan [repository] -c FILE` / `--config FILE`. +It selects one YAML or JSON file; the flag is absent by default. Without it, +existing commands do not load project files and retain their scan defaults. A missing or +invalid selected file exits with code `2`. The repository still comes from the +existing positional argument, defaulting to the invocation directory. The file +does not select a repository in this first format. + +The existing `scan ... --dry-run` inspects that invocation, including file values, +explicit CLI overrides, their sources, and local target checks. Keep existing +output fields and formats; document any additive fields for resolved deep settings +and project-file provenance. Configuration checks do not prove that authentication, +a provider, a model, or a Codex runtime will work. Checks that only Codex can perform +remain runtime checks. + +Initially, `--config` applies only to `scan`. +`bulk-scan` and `scan-components` should not advertise it until their adapters +implement the same resolution behavior. No project configuration is loaded by +finding, publication, authentication, or service commands as a side effect of this +increment. + +A project file could contain the following. Paths describe an illustrative project +with the file at its root; the cost limit is an example, not an estimate of scan +cost. + +```yaml +# Proposed codex-security.yaml + +scan: + mode: standard + scope: + paths: [src, packages] + knowledgeBase: + - SECURITY.md + - docs/architecture.md + instructionsFile: security/scan.md + +codex: + model: gpt-5.6-sol + model_reasoning_effort: xhigh + +limits: + maxCostUsdPerScan: 10 + +policy: + failOnSeverity: high +``` + +A file contains only the settings a team wants to change; `{}` uses the existing defaults. +Omitting the output directory preserves the existing private artifact location. +`SECURITY.md` continues to describe security expectations; YAML holds execution +settings and the machine-readable exit policy. + +The optional root `$schema` key is editor metadata. YAML may instead use a +`yaml-language-server` comment, as in Promptfoo. The installed CLI uses its bundled +schema; it does not fetch or trust a validator named by the file. See the equivalent +[YAML](examples/codex-security.yaml) and +[JSON](examples/codex-security.json) examples with local generated-schema references. + +| Proposed field | Meaning | Default | +| -------------------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `$schema` | Optional editor schema URI or relative path; not a runtime validator selector | Unset | +| `auth` | Existing `auto`, `chatgpt`, or `api-key` credential-source choice; never a credential value | `auto` | +| `scan.mode` | Existing standard/deep mode | `standard` | +| `scan.scope` | One scope variant, described below | Whole repository | +| `scan.knowledgeBase` | Context files or directories | Empty list | +| `scan.instructionsFile` | Additional scan instructions, equivalent to `--scan-prompt-file` | Unset | +| `scan.validationFile` | Custom validation, equivalent to `--validation-prompt-file` | Built-in validation; custom validation remains incompatible with deep mode | +| `scan.deep` | Deep discovery defaults | Existing deep defaults | +| `codex` | Native Codex configuration keys | Existing isolated Codex Security defaults | +| `limits.maxCostUsdPerScan` | Estimated USD limit for one launched scan attempt | No limit | +| `policy.failOnSeverity` | Exit threshold: `critical`, `high`, `medium`, or `low` | Report-only | +| `output.directory` | Artifact directory outside the scanned Git worktree | Existing private state/artifact location | + +`scan.scope` accepts exactly one of these alternatives: + +```yaml +# Scope example: repository-relative paths, not glob patterns. +paths: [src, packages] +``` + +```yaml +# Scope example: committed changes. A base is required; head defaults to HEAD. +diff: + base: origin/main + head: HEAD +``` + +```yaml +# Scope example: staged and unstaged changes. Base defaults to HEAD. +workingTree: + base: HEAD +``` + +Omit `scan.scope` to select the whole repository. Reuse existing target validation +and Git comparison semantics. This proposal does not add glob expansion, exclusion +patterns, or support for deep diff scans. + +The `codex` object uses the existing native vocabulary. `--model` maps to +`codex.model`, `--effort` to `codex.model_reasoning_effort`, and `--provider` to the +existing native provider selection/presets. Do not add another top-level YAML +`model` or `provider` field. Existing plugin ownership, multi-agent requirements, +credential handling, and permission restrictions continue to apply. YAML does not +make previously unsupported native overrides valid. Wrapper path rules below do +not reinterpret paths inside native configuration. + +Native profile selection remains a separate step from merging the same key across +layers. Today, a selected profile's model or effort can supersede a root value set +with `--model` or `--effort`. Preserve that behavior initially and show the effective +selection in dry-run output. Repairing convenience-flag/profile conflicts and the +special handling of `--provider openai` is a separate compatibility change; do not +promise universal CLI precedence before it is implemented and tested. The examples +use root native settings without profiles. + +A deep-scan file could instead include: + +```yaml +# Proposed codex-security.deep.yaml + +scan: + mode: deep + scope: + paths: [src] + deep: + workers: 4 + subagentsPerWorker: 3 + stopAfterNoNew: 4 + stopAfterConsecutiveErrors: 3 + maxDiscoveryRuns: 40 + maxTimeHours: 96 + +limits: + maxCostUsdPerScan: 25 +``` + +The values under `scan.deep` are the current deep defaults; the USD limit is +illustrative. `subagentsPerWorker` permits zero; worker and run counts retain their +positive-integer requirements. `maxTimeHours` retains the existing positive-number +and 96-hour maximum constraints. It limits discovery time, not all work in a larger +workflow. + +A file may retain deep defaults while selecting standard mode. Validate that block +when loading the file, but omit it from the active standard scan. Explicit +deep-only CLI options with standard mode should continue to fail. A selected custom +validation file with deep mode also remains an error. + +`limits.maxCostUsdPerScan` preserves the meaning of the existing per-scan limit. +In-flight requests can finish above the estimate-based threshold. It does not +promise a cap on component planning, cross-scan matching, retries across an entire +batch, or subsequent patching and publication commands. A total-run limit should +be a separate feature with accounting for every included model operation. + +The resolution rules should be part of the public contract: + +1. For a new scan, apply built-in defaults, applicable legacy user deep settings, + the selected project file, and explicitly supplied CLI values, in that order. + This precedence applies to the same setting/key; native profile selection keeps + its existing semantics. Credentials, executable discovery, state paths, and + integration environment defaults retain their independent rules. +2. Route commands and handle help/schema requests before reading project files. + Load exactly the file selected by `-c`, supporting `.yaml`, `.yml`, and `.json` + as the same input contract. Do not discover another file first or interpret the + path as a glob. Parsing a file must + not initialize authentication, providers, scan history, or the Codex runtime. +3. Resolve the CLI repository relative to the invocation directory, as today. Keep + repository selection out of the initial project schema. A file in another + directory does not change the selected repository. +4. Resolve project-file context, instruction, validation, and output paths relative to the + config file. Resolve CLI file paths relative to the invocation directory. Scope + paths remain relative to the selected repository. Normalize these wrapper-owned + paths while their origin is known; do not recursively rewrite native Codex + values. Preserve existing protected-root, output, and credential checks. +5. Keep absent CLI values absent through both argument parsing and schema parsing. + Making a default-bearing schema partial is not sufficient. Merge explicit + values using presence information or an input schema without defaults. Preserve + false, valid zero values, and supported empty lists; do not merge by truthiness. +6. Merge wrapper settings by field and replace lists. Repeated CLI `--path` or + `--knowledge-base` arguments replace the corresponding file list. A CLI scope + selector replaces the whole file variant: `--diff REF` must not inherit file + paths. Dependent flags such as `--head` refine the selected compatible scope + after merging; `--base` keeps its working-tree meaning. Contradictory explicit + scope selectors remain errors. +7. Map convenience flags to their existing native keys and retain duplicate/alias + checks within the CLI layer. Overriding a file value is not a duplicate argument. + Merge native objects using existing native configuration code. Do not flatten + selected profiles or silently change native resolution as part of adding a file + loader. +8. Reject unknown wrapper keys and invalid field types when parsing the file. + Validate cross-field combinations on the resolved active + scan, so CLI overrides can change the mode or scope. Reuse current checks inside + `codex`; leave validation that requires Codex to the runtime. + +For example, from a project root, `scan . -c security/codex-security.yaml` keeps +that project as the target. YAML instruction files resolve relative to `security/`; +`scan.scope.paths` resolves relative to the project root. CLI file overrides retain +their invocation-relative behavior. Verify these rules on Windows as well. + +Replacement is not the same as clearing. The existing CLI has no general reset for a configured severity policy, cost +limit, context list, or whole-repository scope. Its existing `--no-working-tree` +can disable a configured working-tree scope, but cannot clear another scope kind. Initially, edit the file or select an alternative file that omits those +settings; omitting `-c` skips all project settings. Do not claim that every YAML +setting has an expressible CLI reset. An empty context list is an empty list; +`null` is not a general reset operator for wrapper fields. A later discovery +increment can add the project-only bypass described below. + +Keep the first format limited to settings. Do not add embedded credentials, +executable JS/TS config files, wrapper shell hooks, environment templating, remote +includes, `extends`, multiple-file merging, or a second project-profile hierarchy. +Native Codex settings, including profiles and tool configuration, retain their +existing capabilities and checks; they are not made inert by being written in +YAML. Separate files selected with `-c` are sufficient for initial CI and deep-scan +workflows. Use existing environment-based credential mechanisms rather than +creating a new YAML environment loader. + +Patching, PR creation, publication, hook installation, archival, and post-scan +workflows remain explicit commands/options. Do not add automatic `patch: true` or +`publish: true` behavior to discovered YAML. Machine-specific `--python` and +`--plugin-path` overrides retain their existing explicit CLI/SDK/environment paths +rather than becoming project defaults. These choices keep the first increment +focused and preserve the distinction between repository data and authorization. + +The legacy deep TOML file needs a compatibility adapter, not an automatic rewrite: + +| Existing `[deep_scan]` key | Proposed project key | +| ------------------------------- | -------------------------------------- | +| `workers` | `scan.deep.workers` | +| `subagents` | `scan.deep.subagentsPerWorker` | +| `stop_after_no_new` | `scan.deep.stopAfterNoNew` | +| `stop_after_consecutive_errors` | `scan.deep.stopAfterConsecutiveErrors` | +| `max_discovery_runs` | `scan.deep.maxDiscoveryRuns` | +| `max_time_hours` | `scan.deep.maxTimeHours` | + +Continue accepting legacy `workers = "auto"` while reading old TOML and normalize it +to the existing value of four. Do not add that legacy value to the new format. +Project settings and explicit flags override user deep defaults. Reuse or generate +from the existing [deep configuration definitions](../../plugins/codex-security/scripts/deep_scan_config.py) +so the YAML loader does not introduce another hand-maintained set of defaults. + +Before the prototype, preflight and recipe construction selected explicit +TypeScript options, while runtime preparation separately read the legacy TOML. The Python resolver also supported +`stop_after_consecutive_errors`, which the TypeScript adapter did not expose. +Resolve all six effective settings once, validate them with the existing rules, +and pass that result to dry run, execution, and recipe persistence. The runtime +must consume those resolved values instead of rereading user/project configuration. +The prototype adds the YAML adapter; it does not require a new public CLI flag. +Resolving legacy deep settings earlier also changes deep dry runs without `-c`: +they can show those defaults and reject invalid legacy settings before execution. +Document that validation/output change explicitly; it does not change the actual +deep-scan defaults. + +The CLI should parse project input into typed settings, then resolve those settings +to the existing `CodexSecurityConfig` and scan inputs. Keep file parsing, field +resolution, local preflight, and runtime preparation separate. Do not build a +general configuration framework or consolidate all command metadata before the +single-scan path works. The relevant current implementation is in +[CLI registration](../../sdk/typescript/src/cli.ts), +[native configuration](../../sdk/typescript/src/config.ts), +[scan APIs and saved recipes](../../sdk/typescript/src/api.ts), and +[target resolution](../../sdk/typescript/src/targets.ts). + +Generate and package an editor JSON Schema with the first file-loader increment, +using a canonical serializable Zod input definition and explicit input-mode +conversion. Use Draft-07 for this project contract, matching Promptfoo; leave +existing Draft 2020-12 artifact contracts unchanged. Check the same valid and +invalid inputs against Zod and Ajv without inserting defaults, coercing types, or +stripping unknown wrapper keys. File loading uses the generated schema with the +existing Ajv engine, avoiding Zod's cloning behavior for reserved object keys. +The [schema design](configuration-schema.md) +defines the boundary between structural input checks and active-scan validation. + +Incur's command schemas and help remain available. `scan --schema --json` describes +CLI arguments/options, not the project-file schema. Native Codex configuration, +scan results, and recipes retain their separate contracts. There is no parser +replacement or new orchestration runtime in this proposal. + +SDK users should explicitly request file loading. Existing `CodexSecurity.run()` +calls must not start discovering YAML in the caller's current directory. Share +schemas and resolution logic between CLI and SDK while preserving explicit library +inputs and the current plugin configuration format. + +The initial recipe change should record resolved active settings, including values +that came from project files or legacy deep defaults, using existing private artifact +storage and credential exclusions. Build it from the resolved scan, not the raw +CLI options. Preserve existing revision and plugin metadata. `scans rerun` should +consume these saved settings without rediscovering current project files or changing the +recorded deep defaults. Older recipes need an explicit compatibility path. + +This does not promise complete input replay. Preserve existing requirements to +re-supply prompts that recipes do not retain; referenced context can still use +current file contents. State that limitation when rerunning. Recording a source +revision also does not make rerun restore that checkout. Report the source actually +scanned. A separate replay design should cover retaining prompt/context contents, +missing-input errors, source restoration, and runtime/plugin version pinning. A +path or hash alone is insufficient to reproduce an edited or deleted input. + +After the explicit-file path is proven, a separate increment can add the following +workflow conveniences: + +| Proposed syntax or behavior | Contract | Compatibility | +| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| Discover `codex-security.yaml` for `scan` | Look only in the invocation directory. No parent walk, cloned-repository discovery, or implicit file merging. | Deliberate new behavior when that file exists; never applied to unrelated commands. An explicit `-c` skips discovery entirely. | +| `scan [repository] --no-config` | Skip project YAML only. Keep legacy deep defaults, credentials, managed policy, and environment-based runtime/state settings. | Reject combination with `--config`. This is not a clean-room run or a bypass for existing user configuration. | +| `init [directory]` | Write a small commented YAML file; default to the invocation directory and leave existing files unchanged. | No login, dependency installation, inference, or scan. | +| `config validate [-c FILE]` | Validate the file's wrapper schema and combinations without loading runtime providers. Exit `0` when valid and `2` when invalid or missing. | Separate from the existing finding-validation command. Does not certify runtime availability. | +| `config show [-c FILE \| --no-config] [--json]` | Show file/default settings and their sources, even when no project file exists. | No inference or raw credential-bearing config dump. The existing scan dry run remains the invocation-specific preview. | + +Keep help, version, and schema output independent of file validity. These commands +should use the same parser/resolver, not instantiate a scan to inspect settings. +Discovery also needs an explicit review of native tool/profile settings in +repository-owned YAML; its introduction should not be hidden in the initial loader +change. No new bypass for the legacy deep TOML is proposed here. + +Once configuration resolution is shared, a later increment can let +`scan -c FILE` describe a monorepo component plan or an explicitly selected repository +portfolio. Use a single `repository` with explicit `components`, or a mutually +exclusive `repositories` list. Preserve full revision pinning for portfolio inputs, +component report aggregation, retry receipts, and separate repository histories. +Keep the current CSV and JSON inputs as adapters to the same internal plan. + +That increment should introduce an outer `execution.concurrency` and +`execution.maxAttempts`, separate from `scan.deep.workers` and +`scan.deep.subagentsPerWorker`. It must preserve the current standard-only +component behavior until another change adds deep component support. Automatic +component planning remains explicit model work; `init`, config inspection, and +local dry-run checks should not silently perform it. + +The following CLI changes are candidates for that later compatibility discussion, +not prerequisites for the first YAML increment: + +| Current surface | Possible preferred surface | Compatibility requirement | +| --------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| `bulk-scan` and `scan-components` | `scan -c FILE` with explicit orchestration input | Retain CSV/JSON and old-command adapters; do not infer components from ordinary paths. | +| `--workers` on bulk/components | `--concurrency` | Preserve the old outer-concurrency meaning as an alias. | +| `--workers` on deep scans | `--deep-workers` | Preserve the old discovery-worker meaning as an alias. | +| `--subagents` | `--subagents-per-worker` | Preserve zero and per-worker semantics. | +| `--max-cost` | `--max-scan-cost` | Never silently reinterpret an existing per-attempt limit as a total-run limit. | +| `--diff BASE` | Also accept `--base BASE [--head HEAD]` for committed changes | Retain `--working-tree --base REF`, alias checks, and existing Git comparison semantics. | +| `login`, `login status`, `logout` | `auth login`, `auth status`, `auth logout` | Keep existing entrypoints as compatibility aliases. | +| `install-hook` | `hooks install` | Preserve hook installation behavior. | +| `dedupe` | `findings dedupe` | Make its model and findings-API work explicit. | + +Keep `scan [repository]` short and retain useful direct finding-action commands. +Keep `--to` as the only generic integration selector, with destination-specific +fields such as `--linear-team` and `--linear-project`. Do not generalize integration +fields whose meanings differ. + +Output cleanup should proceed separately from command renaming. Help should list +only formats and filters that a command supports, including accepted JSON aliases. +Keep result serialization distinct from `--export-format`, `--output`, and +`--output-dir`. Do not repurpose the existing `--format` flag or silently change +structured output as part of adding YAML. Matching, comparison, and deduplication +should state when they invoke models or persist results. + +Preserve scan exit behavior during the rollout: `0` for completed work without a +policy breach, `1` for a finding-policy breach, `2` for invalid, failed, or incomplete +work, and existing cancellation codes. YAML must not make a partial scan pass. +`policy.failOnSeverity` changes the exit decision, not which findings are retained. +When batch/components adopt the policy, operational failures take precedence over +a clean severity result. The sample threshold of `high` does not change the +report-only default. + +The implementation should be delivered in focused increments: + +1. Add explicit `scan -c FILE` for equivalent YAML/JSON input, its generated and + packaged editor schema, shared resolution, and integration with existing dry + run and scan execution. Resolve effective deep + settings once and persist those values in recipes. Preserve scan defaults without + `-c` and document earlier deep validation; do not block on CLI renaming or a + general metadata refactor. +2. Add agreed discovery, project-only bypass, initialization, and config inspection + using that same resolver. Test malformed discovered files and explicit-file + selection through the real CLI. +3. Adapt batch/component inputs and CI policy to shared settings while preserving + orchestration, inventories, retry behavior, and capability limits. The proposed + portfolio/component schema needs its own review before accepting new shapes. + +Review alias/profile precedence repairs, output/help cleanup, command renaming, +complete input replay, and total-run cost accounting independently. They address +real concerns but are not all prerequisites for loading one project file. Any +accepted behavior change needs its own compatibility notes and validation. + +Acceptance checks should cover observable behavior: + +- Equivalent supported CLI and YAML input resolves to the same effective options. + Exercise the actual argument parser and action schema, not just a hand-built + option object passed to a resolver. +- Equivalent YAML and JSON input passes the same input contract. Editor metadata + does not select another runtime validator, and the packaged schema works offline. + The file loader and generated schema agree on structural input fixtures without + modifying them, including reserved unknown wrapper keys. +- Absent arguments remain absent through both parser layers. Explicit false/zero + and supported list values survive; scope selection replaces the file variant, + and dependent scope flags are checked after merging. +- Explicit selection reads only the selected file. Legacy deep settings and native + profiles follow the documented rules, including from another working directory. +- Dry run, execution, and new recipes consume the same resolved deep defaults, + including the error-stop setting previously missing from the TypeScript adapter. +- Repository-relative and config-relative paths remain distinct and work on + Windows; current output and protected-path rules remain enforced. +- Invalid wrapper keys, types, and active combinations fail before + inference. Help/schema output and local dry runs do not launch models, publish + results, or initialize live scan history just to inspect settings. +- New recipes do not absorb edited YAML or changed deep defaults. Reruns identify + their current-file/source limitations and retain explicit prompt requirements. + Exact input replay remains outside the initial acceptance claim. +- Existing credential exclusions apply to configuration inspection and recipes. + Existing machine output and scan exit codes remain compatible. + +The first implementation review should settle the field names, native pass-through +boundary, and effective deep-settings adapter. The later discovery increment adds +checks for project-only bypass, invalid ambient files, and inspection without +runtime initialization. Broader command restructuring and portfolio shape should +remain separate from proving the single-scan configuration contract. diff --git a/docs/proposals/configuration-schema.md b/docs/proposals/configuration-schema.md new file mode 100644 index 000000000..bdd244b30 --- /dev/null +++ b/docs/proposals/configuration-schema.md @@ -0,0 +1,197 @@ +# Promptfoo alignment and JSON Schema design + +**Status: design implemented in the local CLI prototype.** This supplements the +[project configuration proposal](cli-and-project-configuration.md) and its +[implementation review](cli-and-project-configuration-review.md). The +[generated JSON Schema](../../sdk/typescript/schemas/project-config.schema.json) and equivalent +[YAML](examples/codex-security.yaml) / [JSON](examples/codex-security.json) examples +use the prototype input contract. See the [prototype guide](../project-configuration.md) +for commands and limits; this feature has not been released. + +The proposal aligns closely with Promptfoo's workflow and schema-generation +pattern. Its configuration vocabulary is specific to repository security scans; +the files are not interchangeable with Promptfoo configurations. The first +increment also delivers less convenience than Promptfoo because discovery and +initialization are deferred. Editor schema support should ship with the first +usable file loader, even if those commands follow later. + +| Area | Promptfoo | Proposed Codex Security behavior | Alignment | +| ---------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| Normal invocation | `eval -c FILE` with file defaults and CLI overrides | `scan -c FILE` with file defaults and explicit CLI overrides | Close workflow match; native profile semantics remain separately documented. | +| File formats | YAML, JSON, and executable JS/TS variants | YAML and JSON as two encodings of one input object | Match the common data formats; omit executable configuration. | +| Discovery and initialization | Discoverable `promptfooconfig` and `init` | Explicit selection first; discovery and `init` in a follow-up | A staged product difference, not a different long-term goal. | +| Editor support | Zod-derived JSON Schema, schema comments, generated assets checked in CI | Generated JSON Schema and matching examples in the first increment | Adopt directly, with input/runtime agreement checks. | +| Configuration vocabulary | Prompts, providers/targets, tests, red-team settings | Scan scope, deep discovery, native Codex settings, scan limits, finding exit policy | Different domains; do not copy field names without matching meaning. | +| Runtime controls | Both `commandLineOptions` and `evaluateOptions` | One canonical location per setting | Intentional simplification. | +| Composition | Multiple files, globs, field-specific merges, references, environment templates | One explicit file; ordinary literal values and documented path rules | Smaller initial contract. | +| Validation behavior | Some coercion, unknown-key stripping, warning paths, and later resource loading | Strict wrapper input types/keys, then active-scan checks after merging | Deliberate behavior difference. | +| Native configuration | Promptfoo provider abstractions | Existing native Codex vocabulary under `codex` | Preserve the thin-wrapper architecture. | +| Versioning | The inspected project schema has no required format-version field | No project format-version field; schema ships with the package | Same minimal configuration shape. | + +This comparison is based on Promptfoo 0.121.19 at +[ce4c59d](https://github.com/promptfoo/promptfoo/tree/ce4c59d93f055c9dfbbb66d841f681909089ddf0), +including its [file reader](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/util/config/load.ts#L328-L414), +[schema generator](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/scripts/generateJsonSchema.ts#L88-L155), +and [generated-asset CI check](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/.github/workflows/main.yml#L321-L335). + +There are several different schema contracts here. They should not become one +large schema merely because they all use JSON Schema: + +| Contract | Owner and purpose | Proposed treatment | +| ----------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | +| Project input | Wrapper-owned settings in YAML/JSON | Add one canonical serializable input definition and generate its editor schema. | +| Native `codex` configuration | Codex's own settings and profiles | Retain native validation; do not hand-maintain a second exhaustive native schema. | +| CLI introspection | Incur's `scan --schema --json` describes arguments and options | Preserve it. It is not the project-file schema and includes action-specific flags excluded from project defaults. | +| Scan artifacts | Findings, coverage, manifests, and other existing plugin contracts | Leave their schemas and versioning unchanged. They describe results, not user configuration. | +| Resolved settings and recipes | Internal effective inputs and saved rerun metadata | Keep separate types and compatibility checks. Do not invent another public file format until a consumer needs one. | + +The existing [CLI schema tests](../../sdk/typescript/tests-ts/cli.test.ts) and +[artifact validator](../../sdk/typescript/src/contract.ts) already establish those +separate responsibilities. Artifact schemas use Draft 2020-12 and the SDK already +depends on Ajv 8.20.0. No new JSON Schema engine is needed. + +For project input, use a small Zod definition containing serializable values only, +and generate JSON Schema from that definition. This follows Promptfoo's approach +and fits the existing Zod-based CLI. Infer the TypeScript input type from the same +definition. Keep callbacks, abort signals, runtime objects, path resolution, and +default application outside it. The prototype uses the generated schema with the +SDK's existing Ajv engine for file validation. Direct probes found that Zod's +cloned output silently omits reserved object keys, even in strict wrapper objects; +Ajv rejects unknown wrapper keys and retains native input for the existing +override checks. Existing artifact schemas remain authoritative for their own +contracts; this is not a migration of all schemas to Zod. + +The proposed generation settings are: + +```ts +z.toJSONSchema(ProjectConfigInputSchema, { + target: "draft-07", + io: "input", + unrepresentable: "throw", +}); +``` + +Draft-07 matches Promptfoo's project schema and Codex's published native schema. +It supports the needed objects, enums, unions, and numeric constraints. There is +no need to change existing Draft 2020-12 artifact schemas. Current +[YAML Language Server documentation](https://github.com/redhat-developer/yaml-language-server#yaml-language-server) +supports both dialects; this choice does not claim that editors require Draft-07. + +Zod's default conversion describes parsed output, while `io: "input"` describes +what authors supply. Use strict objects for wrapper-owned sections so the generated +schema rejects unknown keys. Keep unsupported runtime values out of the schema rather than +converting them to an unconstrained placeholder. These options and their limits +are documented in [Zod's JSON Schema guide](https://zod.dev/json-schema). + +The generator is only part of the contract. Direct probes against Promptfoo's +runtime `UnifiedConfigSchema` and checked-in JSON Schema produced these results: + +| Input | Runtime Zod schema | Generated JSON Schema | +| ---------------------------------------- | ----------------------------- | ------------------------------- | +| Ordinary prompt plus `providers: [echo]` | Accept | Accept | +| Neither `providers` nor `targets` | Reject | Accept | +| Both `providers` and `targets` | Reject | Accept | +| Unknown root key | Accept and strip | Reject | +| Root `$schema` metadata | Accept and strip | Reject in direct Ajv validation | +| `commandLineOptions.maxConcurrency: "2"` | Accept and coerce to a number | Reject | +| Object-valued transform | Reject | Reject | + +These were direct schema comparisons, not fresh evaluations or editor tests. The +runtime's [provider/target refinement](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/types/index.ts#L1320-L1348) +is absent from the inspected generated root schema. Its output-oriented numeric +schema also does not describe every coerced input. Promptfoo has useful +[Ajv regression tests](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/test/config-schema.test.ts#L30-L60), +but generation and a valid metaschema do not by themselves establish agreement. + +For the proposed wrapper schema, use normal structural unions where possible. +The input scope is an `anyOf` of three strict objects: one requires `paths`, one +`diff`, and one `workingTree`. Because each branch rejects the other branches' +keys, this permits exactly one variant. Avoid encoding the rule only in a custom +refinement that an editor schema might lose. + +**Defaults belong to resolution.** A JSON Schema `default` is an annotation, not +an instruction to populate a missing property during validation. The schema includes +built-in defaults as documentation hints; a legacy user setting may still supply a +different effective value. Keep Ajv's `useDefaults`, `coerceTypes`, and +`removeAdditional` disabled. Keep Zod input fields optional without `.default()` +or `.prefault()`. Apply defaults once, after retaining which values each layer +actually supplied. See [JSON Schema annotations](https://json-schema.org/understanding-json-schema/reference/annotations) +and [Ajv's data-modification options](https://ajv.js.org/options.html#options-to-modify-validated-data). + +**Input validity and executable scan validity are separate.** The input schema +checks wrapper keys, field types, the scope union, and current +numeric bounds. It deliberately does not reject every combination of individually +valid settings before CLI overrides. For example, a file containing deep mode and +a diff scope is structurally valid input, but cannot execute unless an override +changes the incompatible mode or scope. The existing scan checks must reject an +incompatible resolved combination before inference. The same applies to a custom +validation file with active deep mode. A future `config validate` command should +validate the resolved file/default invocation as well as its structure. + +Filesystem existence, Git refs, protected output paths, authentication, model +availability, and cost estimation are not certified by JSON Schema. Nor does the +schema resolve file-relative paths. These remain existing local/native checks; +do not add custom schema keywords that secretly perform I/O. + +**The native block needs a narrower promise.** Official OpenAI documentation links +a [native Codex configuration schema](https://learn.chatgpt.com/docs/config-file/config-reference#configtoml). +The currently published schema accepted this checkout's default native settings in +a local Ajv probe. However, it is not identical to the wrapper's contract: it +rejects unknown native keys that the wrapper currently passes through, while it +permits plugin-loading configuration that the wrapper owns and rejects. + +The prototype therefore checks common model/provider key types and permits other +JSON-valued native keys, with existing native and wrapper checks still required. +This means typo detection and completion are intentionally incomplete inside +`codex`. Do not claim that the project schema validates every native option. + +If full native editor completion is added, reuse an upstream schema matched to the +packaged Codex version. Bundle its references and apply existing wrapper rules +without forking its vocabulary by hand. Do not use the changing latest-schema URL +as a mandatory runtime validation gate. Matching one current default object is +not enough to establish complete version compatibility. + +**Editor metadata does not select runtime validation.** + +| Location | Field | Meaning | +| --------------- | --------- | --------------------------------------------------------------------------------------------------------------------- | +| Schema document | `$schema` | JSON Schema dialect, such as Draft-07. | +| Schema document | `$id` | Optional identity of that schema document. The prototype omits it; editors resolve references from the file location. | +| Project file | `$schema` | Optional editor hint pointing to the project schema; not a request for the CLI to fetch or trust another validator. | + +The YAML example uses Promptfoo's familiar language-server comment, with a +relative path to the generated package schema. The JSON example uses a root `$schema` +property explicitly allowed by the input definition. After removing that metadata, +both examples describe exactly the same settings. Runtime validation uses the +schema bundled with the installed package. There is no project format-version field. + +The prototype includes the generated schema in the npm package at +`@openai/codex-security/schemas/project-config.schema.json`. The +[package manifest](../../sdk/typescript/package.json) exports that path, and the +package check requires the file. Editors and CI can use the matching package +copy offline. A hosted schema URL can follow once it is actually available. +Keep an exact package-version copy for clients that need to match an older CLI; +a floating latest schema can describe fields that an installed CLI does not accept. +Do not add or repurpose a CLI flag merely to print a schema already shipped as a +file. In particular, preserve the existing command-introspection `--schema` output. + +The first implementation's verification should cover: + +- Metaschema validity and deterministic generation, with a CI check for changes in + the generated artifact, following Promptfoo's generated-asset check. +- The same positive and negative input fixtures through Zod and Ajv, including + unknown keys, literal numeric types, zero subagents, scope exclusivity, metadata, + empty configurations, and absence of inserted defaults. +- YAML and JSON examples resolving to the same settings after editor metadata is + removed, plus real CLI tests for file/flag precedence and active-scan validation. +- Schema references resolving from the packed package without a network request, + and at least one actual YAML/JSON language-service completion/diagnostic check. +- Existing result schemas, command introspection, credential exclusions, and + native runtime checks remaining unchanged unless separately reviewed. + +For this proposal, positive/negative draft fixtures agreed between Zod 4.4.3 +and Ajv 8.20.0, and accepted inputs were neither default-filled nor stripped. +Eight Promptfoo comparisons and three native-schema probes informed the boundaries +above. Those initial comparisons predated implementation. The prototype now +keeps 26 cases in [regression tests](../../sdk/typescript/tests-ts/project-config.test.ts), +with separate [CLI tests](../../sdk/typescript/tests-ts/cli-project-config.test.ts). diff --git a/docs/proposals/examples/codex-security.json b/docs/proposals/examples/codex-security.json new file mode 100644 index 000000000..93232568f --- /dev/null +++ b/docs/proposals/examples/codex-security.json @@ -0,0 +1,13 @@ +{ + "$schema": "../../../sdk/typescript/schemas/project-config.schema.json", + "scan": { + "mode": "standard", + "scope": { "paths": ["src"] } + }, + "codex": { + "model": "gpt-5.6-sol", + "model_reasoning_effort": "xhigh" + }, + "limits": { "maxCostUsdPerScan": 10 }, + "policy": { "failOnSeverity": "high" } +} diff --git a/docs/proposals/examples/codex-security.yaml b/docs/proposals/examples/codex-security.yaml new file mode 100644 index 000000000..9e6398868 --- /dev/null +++ b/docs/proposals/examples/codex-security.yaml @@ -0,0 +1,17 @@ +# yaml-language-server: $schema=../../../sdk/typescript/schemas/project-config.schema.json +# Local prototype format; not yet released. + +scan: + mode: standard + scope: + paths: [src] + +codex: + model: gpt-5.6-sol + model_reasoning_effort: xhigh + +limits: + maxCostUsdPerScan: 10 + +policy: + failOnSeverity: high diff --git a/plugins/codex-security/plugin-files.json b/plugins/codex-security/plugin-files.json index 2d0004e21..c74756e66 100644 --- a/plugins/codex-security/plugin-files.json +++ b/plugins/codex-security/plugin-files.json @@ -42,6 +42,7 @@ "schemas/tools/worker-threat-model.schema.json", "scripts/config_preflight.py", "scripts/deep_scan_config.py", + "scripts/deep_scan_defaults.json", "scripts/deep_scan_workbench.py", "scripts/filesystem_identity.py", "scripts/finalize_scan_contract.py", diff --git a/plugins/codex-security/scripts/deep_scan_config.py b/plugins/codex-security/scripts/deep_scan_config.py index 36130417d..5d3ffcd1a 100644 --- a/plugins/codex-security/scripts/deep_scan_config.py +++ b/plugins/codex-security/scripts/deep_scan_config.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import json import math import os from pathlib import Path @@ -13,12 +14,15 @@ except ModuleNotFoundError: # pragma: no cover - Python 3.10 only import tomli as tomllib -DEFAULT_WORKERS = 4 -DEFAULT_SUBAGENTS = 3 -DEFAULT_STOP_AFTER_NO_NEW = 4 -DEFAULT_STOP_AFTER_CONSECUTIVE_ERRORS = 3 -DEFAULT_MAX_DISCOVERY_RUNS = 40 -DEFAULT_MAX_TIME_HOURS = 96 +DEFAULTS = json.loads( + Path(__file__).with_name("deep_scan_defaults.json").read_text(encoding="utf-8") +) +DEFAULT_WORKERS = DEFAULTS["workers"] +DEFAULT_SUBAGENTS = DEFAULTS["subagents"] +DEFAULT_STOP_AFTER_NO_NEW = DEFAULTS["stopAfterNoNew"] +DEFAULT_STOP_AFTER_CONSECUTIVE_ERRORS = DEFAULTS["stopAfterConsecutiveErrors"] +DEFAULT_MAX_DISCOVERY_RUNS = DEFAULTS["maxDiscoveryRuns"] +DEFAULT_MAX_TIME_HOURS = DEFAULTS["maxTimeHours"] MAX_TIME_HOURS = 96 CONFIG_KEYS = { "workers", @@ -130,8 +134,6 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--available-parallelism", type=int, required=True) args = parser.parse_args() - import json - print(json.dumps(resolve_deep_scan_config(args.available_parallelism), sort_keys=True)) diff --git a/plugins/codex-security/scripts/deep_scan_defaults.json b/plugins/codex-security/scripts/deep_scan_defaults.json new file mode 100644 index 000000000..84dcb3f9c --- /dev/null +++ b/plugins/codex-security/scripts/deep_scan_defaults.json @@ -0,0 +1,8 @@ +{ + "workers": 4, + "subagents": 3, + "stopAfterNoNew": 4, + "stopAfterConsecutiveErrors": 3, + "maxDiscoveryRuns": 40, + "maxTimeHours": 96 +} diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 30cd43a7d..27f6b92f6 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -135,21 +135,22 @@ Constructor options: Options for `security.run(repository, options)` and `security.preflight(repository, options)`: -| Option | Description | -| ----------------------- | ------------------------------------------------------------------------------ | -| `auth` | Credential source: `"auto"`, `"chatgpt"`, or `"api-key"`. | -| `safetyIdentifier` | Stable hashed end-user ID for model requests; requires API-key authentication. | -| `target` | Repository, repository-relative paths, committed diff, or working-tree diff. | -| `mode` | `"standard"` or `"deep"`; deep mode supports repositories and paths. | -| `knowledgeBasePaths` | Architecture documents, security policies, threat models, or directories. | -| `outputDir` | Artifact directory outside the enclosing Git worktree. | -| `archiveExisting` | Archive existing results in `outputDir` before scanning. | -| `maxCostUsd` | Stop when estimated model cost exceeds this positive USD amount. | -| `maxTimeHours` | Deep-scan discovery limit in hours: greater than zero, up to 96. | -| `failureSeverity` | Finding-severity policy to record in the saved scan recipe. | -| `parentScanId` | Parent scan ID for a rerun. | -| `expectedPluginVersion` | Required original plugin version when replaying a scan. | -| `signal` | `AbortSignal` to cancel a scan. | +| Option | Description | +| ---------------------------- | ------------------------------------------------------------------------------ | +| `auth` | Credential source: `"auto"`, `"chatgpt"`, or `"api-key"`. | +| `safetyIdentifier` | Stable hashed end-user ID for model requests; requires API-key authentication. | +| `target` | Repository, repository-relative paths, committed diff, or working-tree diff. | +| `mode` | `"standard"` or `"deep"`; deep mode supports repositories and paths. | +| `knowledgeBasePaths` | Architecture documents, security policies, threat models, or directories. | +| `outputDir` | Artifact directory outside the enclosing Git worktree. | +| `archiveExisting` | Archive existing results in `outputDir` before scanning. | +| `maxCostUsd` | Stop when estimated model cost exceeds this positive USD amount. | +| `stopAfterConsecutiveErrors` | Stop deep discovery after this many consecutive errors (default: 3). | +| `maxTimeHours` | Deep-scan discovery limit in hours: greater than zero, up to 96. | +| `failureSeverity` | Finding-severity policy to record in the saved scan recipe. | +| `parentScanId` | Parent scan ID for a rerun. | +| `expectedPluginVersion` | Required original plugin version when replaying a scan. | +| `signal` | `AbortSignal` to cancel a scan. | Follow scans with `onWorkerStatus` and `onReconnect`. `onSessionEvent` receives saved events with thread IDs and worker numbers. Deep scans can additionally use @@ -160,6 +161,9 @@ and `maximum`. The maximum is a configured cap, not a percentage denominator. `preflight` and CLI `--dry-run` check local inputs without starting Codex or using the network. They don't authenticate, verify model access, resolve Python, inspect the plugin, or run scan-lifecycle callbacks. Dry runs print effective settings. +Deep preflight includes all six resolved deep settings and their origins in +`deepScanSources`. Applicable legacy deep configuration is validated during +preflight rather than after runtime startup. ## Authentication @@ -265,6 +269,62 @@ Use `scan --help` for options, `--version` for the installed version, and `info --json` for package, plugin, runtime, and model details. `--dry-run` runs local preflight checks. +### Project files (local prototype) + +This working tree supports `scan -c FILE` / `scan --config FILE`. The feature is +not yet released. In a locally built or installed package: + +```bash +codex-security scan . -c codex-security.yaml --dry-run --json +codex-security scan . -c codex-security.json --model gpt-5.6-terra +``` + +Select one `.yaml`, `.yml`, or `.json` file. Omitting `-c` preserves existing scan +defaults without discovering files. The repository still comes from the positional +argument or invocation directory. Other commands and SDK `run()` calls do not +load project files automatically. + +```yaml +# yaml-language-server: $schema=./node_modules/@openai/codex-security/schemas/project-config.schema.json +scan: + mode: standard + scope: + paths: [src] +codex: + model: gpt-5.6-sol + model_reasoning_effort: xhigh +policy: + failOnSeverity: high +``` + +All settings are optional; `{}` uses the existing defaults. The schema is included at +`@openai/codex-security/schemas/project-config.schema.json` and generated from +one Zod input definition. The loader validates it with the existing Ajv engine, +without coercion, default insertion, or key stripping. JSON files may use a root +`$schema` string with the same relative path. The hint is editor metadata; the CLI +uses its bundled schema and does not fetch schema URLs. Wrapper keys +and types are strict. Native keys under `codex` retain existing validation and +profile semantics; editor completion there covers common model/provider fields. +CLI `scan --schema --json` still describes command arguments, not project files. + +Settings use built-in defaults, applicable legacy deep defaults, the file, then +explicit CLI values. Lists and scope variants are replaced. `--head` can refine +a file diff and `--base` a file working-tree scope. A selected native profile can +still override root model/effort values. Existing native alias-conflict checks +and the behavior of `--provider openai` are unchanged. + +File context, instruction, validation, and output paths resolve from the file's +directory. CLI file paths resolve from the invocation directory; scope paths +resolve from the repository. The file cannot select a different repository or +enable automatic patching/publication. It does not support executable configs, +environment interpolation, remote includes, or multiple-file merging. + +Dry-run output adds `projectConfig.path` and `projectConfig.sources`, selected +prompt paths, and the finding policy without dumping raw native configuration. +Missing or invalid selected files exit `2`. Help, version, and command schema +output do not load project files. Existing scan and finding-policy exit codes +remain unchanged. + ### Scan options and output `--path` scopes a scan to one or more paths, `--diff` scans committed changes, @@ -383,6 +443,7 @@ await security.run("/path/to/repository", { workers: 2, subagents: 0, stopAfterNoNew: 3, + stopAfterConsecutiveErrors: 2, maxDiscoveryRuns: 10, maxTimeHours: 1.5, }); @@ -400,8 +461,10 @@ max_discovery_runs = 40 max_time_hours = 96 ``` -CLI and SDK options override these defaults. Set `stop_after_consecutive_errors` -in the file; `--codex` cannot configure this section. Worker and run counts must +CLI and SDK options override these defaults. Project files can use +`scan.deep.stopAfterConsecutiveErrors`, and SDK calls can use +`stopAfterConsecutiveErrors`; there is no new CLI flag for it. `--codex` cannot +configure this section. Worker and run counts must be positive integers; `subagents` can be zero. Legacy `workers = "auto"` means four workers. Unknown keys are rejected. @@ -411,6 +474,12 @@ At the deadline, discovery stops; the scan combines and returns completed findin `scan --workers` controls discovery workers within one deep scan; `bulk-scan --workers` controls how many repositories are scanned concurrently. +The project-file deep block uses `subagentsPerWorker` for the existing SDK/CLI +`subagents` setting. A valid deep block can remain inactive in standard mode; +explicit deep CLI options require deep mode. All six active values are resolved +before runtime preparation and saved in new recipes. Complete saved values are +independent of later changes to the legacy TOML file. + ### Runtime configuration and worker limits Scans use these isolated Codex defaults instead of your user or repository @@ -755,6 +824,17 @@ the same destination options for a read-only check. Commands default to the current repository. Select scans by full ID or a unique prefix of at least eight characters. +New recipes retain resolved settings and the authentication choice, not +credentials. Reruns do not reload project files; complete saved deep settings do +not use current legacy defaults. Older partial recipes retain their previous +fallback behavior. Context paths and the current checkout are not immutable input +snapshots. + +Additional scan instructions are not saved. New recipes mark this requirement, +and `scans rerun` refuses to omit them silently; use a new `scan --scan-prompt-file` +or `scan -c` invocation to supply them again. Custom validation keeps its existing +`scans rerun --validation-prompt-file` requirement. + | Command | Purpose | | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | `scans list [REPOSITORY]` | List scans. Filter by artifact root with `--scan-root DIR`. | diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 062cd199a..2a0537c2d 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -23,7 +23,8 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" - } + }, + "./schemas/project-config.schema.json": "./schemas/project-config.schema.json" }, "bin": { "codex-security": "./bin/codex-security.mjs" @@ -31,6 +32,7 @@ "files": [ "bin", "dist", + "schemas", "_bundled_plugin", "LICENSE", "README.md" @@ -44,7 +46,7 @@ "scripts": { "audit:prod": "pnpm audit --prod --audit-level high", "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", - "build": "node --run clean && tsc -p tsconfig.build.json && node scripts/build-dashboard.mjs", + "build": "node --run clean && node scripts/generate-deep-defaults.mjs && tsc -p tsconfig.build.json && node scripts/generate-project-config-schema.mjs && node scripts/build-dashboard.mjs", "build:plugin": "node scripts/build-plugin.mjs", "check:plugin-source": "node scripts/check-plugin-source.mjs", "check:package": "node scripts/check-package.mjs", @@ -59,7 +61,7 @@ "test:mcp": "node --run build:plugin && npm --prefix ../../plugins/codex-security/mcp-app run test:mcp", "test:mutation": "stryker run", "test:package": "node scripts/smoke-package.mjs", - "types": "pnpm run generate:models:check && npm --prefix ../../plugins/codex-security/mcp-app run typecheck && tsc --noEmit" + "types": "node scripts/generate-deep-defaults.mjs --check && pnpm run generate:models:check && npm --prefix ../../plugins/codex-security/mcp-app run typecheck && tsc --noEmit" }, "dependencies": { "@inquirer/prompts": "8.3.0", @@ -78,7 +80,9 @@ "pdfjs-dist": "6.2.108", "react": "19.2.4", "semver": "7.8.5", - "smol-toml": "1.6.1" + "smol-toml": "1.6.1", + "yaml": "2.9.0", + "zod": "4.4.3" }, "devDependencies": { "@openai/apps-sdk-ui": "0.2.2", diff --git a/sdk/typescript/pnpm-lock.yaml b/sdk/typescript/pnpm-lock.yaml index eed94b772..e52e8fb55 100644 --- a/sdk/typescript/pnpm-lock.yaml +++ b/sdk/typescript/pnpm-lock.yaml @@ -59,6 +59,12 @@ importers: smol-toml: specifier: 1.6.1 version: 1.6.1 + yaml: + specifier: 2.9.0 + version: 2.9.0 + zod: + specifier: 4.4.3 + version: 4.4.3 devDependencies: '@openai/apps-sdk-ui': specifier: 0.2.2 diff --git a/sdk/typescript/schemas/project-config.schema.json b/sdk/typescript/schemas/project-config.schema.json new file mode 100644 index 000000000..ba957b233 --- /dev/null +++ b/sdk/typescript/schemas/project-config.schema.json @@ -0,0 +1,211 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "$schema": { + "description": "Editor schema URI or relative path. The CLI does not fetch or select a validator from this value.", + "type": "string", + "minLength": 1 + }, + "auth": { + "default": "auto", + "description": "Credential-source choice only; never a credential value.", + "type": "string", + "enum": ["auto", "chatgpt", "api-key"] + }, + "scan": { + "type": "object", + "properties": { + "mode": { + "default": "standard", + "type": "string", + "enum": ["standard", "deep"] + }, + "scope": { + "description": "One scope variant. Omit for the whole repository. Mode compatibility is checked after overrides.", + "anyOf": [ + { + "type": "object", + "properties": { + "paths": { + "minItems": 1, + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "description": "Literal paths relative to the selected repository." + } + }, + "required": ["paths"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "diff": { + "type": "object", + "properties": { + "base": { "type": "string", "minLength": 1 }, + "head": { + "default": "HEAD", + "type": "string", + "minLength": 1 + } + }, + "required": ["base"], + "additionalProperties": false + } + }, + "required": ["diff"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "workingTree": { + "type": "object", + "properties": { + "base": { + "default": "HEAD", + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "required": ["workingTree"], + "additionalProperties": false + } + ] + }, + "knowledgeBase": { + "description": "Context files or directories, relative to this file. An empty list selects no additional context.", + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "instructionsFile": { + "description": "Additional scan instructions, relative to this file.", + "type": "string", + "minLength": 1 + }, + "validationFile": { + "description": "Custom validation instructions, relative to this file; not supported in active deep scans.", + "type": "string", + "minLength": 1 + }, + "deep": { + "description": "Deep defaults; a valid block may be retained while standard mode is selected.", + "type": "object", + "properties": { + "workers": { + "default": 4, + "description": "Maximum concurrent deep-scan discovery workers.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "stopAfterNoNew": { + "default": 4, + "description": "Stop after this many runs find no new issues.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "stopAfterConsecutiveErrors": { + "default": 3, + "description": "Stop after this many consecutive discovery errors.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "maxDiscoveryRuns": { + "default": 40, + "description": "Maximum deep-scan discovery runs.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "maxTimeHours": { + "default": 96, + "description": "Maximum deep-scan discovery hours (default: 96; maximum: 96).", + "type": "number", + "exclusiveMinimum": 0, + "maximum": 96 + }, + "subagentsPerWorker": { + "default": 3, + "description": "Subagents available to each deep-scan worker. Zero is valid.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "codex": { + "description": "Native Codex overrides. Common key types are checked here; existing native and wrapper restrictions still apply.", + "type": "object", + "properties": { + "model": { "type": "string", "minLength": 1 }, + "model_reasoning_effort": { "type": "string", "minLength": 1 }, + "model_provider": { "type": "string", "minLength": 1 } + }, + "additionalProperties": { "$ref": "#/definitions/__schema0" } + }, + "limits": { + "type": "object", + "properties": { + "maxCostUsdPerScan": { + "description": "Estimated USD limit per launched scan attempt, not a total batch budget. Omit for no limit.", + "type": "number", + "exclusiveMinimum": 0 + } + }, + "additionalProperties": false + }, + "policy": { + "type": "object", + "properties": { + "failOnSeverity": { + "description": "Exit threshold; does not filter retained findings. Omit for report-only behavior.", + "type": "string", + "enum": ["critical", "high", "medium", "low"] + } + }, + "additionalProperties": false + }, + "output": { + "type": "object", + "properties": { + "directory": { + "description": "Artifact directory relative to this file; existing outside-worktree checks still apply.", + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "definitions": { + "__schema0": { + "anyOf": [ + { "type": "string" }, + { "type": "number" }, + { "type": "boolean" }, + { "type": "null" }, + { "type": "array", "items": { "$ref": "#/definitions/__schema0" } }, + { + "type": "object", + "propertyNames": { "type": "string" }, + "additionalProperties": { "$ref": "#/definitions/__schema0" } + } + ] + } + }, + "title": "Codex Security project configuration", + "description": "Input schema for explicitly selected YAML or JSON project files. Filesystem, active scan combinations, native configuration, and runtime availability are checked separately.", + "$comment": "Generated from ProjectConfigInputSchema. Defaults are annotations; apply defaults only after merging input layers." +} diff --git a/sdk/typescript/scripts/check-package.mjs b/sdk/typescript/scripts/check-package.mjs index 333dafd88..8fb3bb704 100644 --- a/sdk/typescript/scripts/check-package.mjs +++ b/sdk/typescript/scripts/check-package.mjs @@ -108,6 +108,7 @@ const required = [ "package/dist/index.js", "package/dist/index.d.ts", "package/dist/cli.js", + "package/schemas/project-config.schema.json", "package/_bundled_plugin/.codex-plugin/plugin.json", ]; @@ -161,6 +162,7 @@ const allowedRoot = new Set([ "package/README.md", "package/LICENSE", "package/bin/codex-security.mjs", + "package/schemas/project-config.schema.json", ]); const distFiles = new Set( [ @@ -180,6 +182,11 @@ const distFiles = new Set( "custom-validation-prompt", "custom-publish", "deep-progress", + "deep-config", + "deep-scan-defaults", + "project-config", + "project-config-schema", + "scan-settings", "errors", "github", "index", @@ -253,6 +260,7 @@ for (const file of files) { const allowed = file.endsWith("/") ? normalized === "package" || normalized === "package/bin" || + normalized === "package/schemas" || normalized === "package/dist" || normalized === "package/dist/server" || normalized === "package/dist/server/dashboard" || diff --git a/sdk/typescript/scripts/generate-deep-defaults.mjs b/sdk/typescript/scripts/generate-deep-defaults.mjs new file mode 100644 index 000000000..8b86e9b97 --- /dev/null +++ b/sdk/typescript/scripts/generate-deep-defaults.mjs @@ -0,0 +1,24 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { format } from "prettier"; + +const defaults = JSON.parse( + await readFile( + new URL( + "../../../plugins/codex-security/scripts/deep_scan_defaults.json", + import.meta.url, + ), + "utf8", + ), +); +const contents = await format( + `// Generated from the plugin deep_scan_defaults.json. Run pnpm build.\nexport const DEFAULT_DEEP_SCAN_SETTINGS = ${JSON.stringify(defaults)} as const;\n`, + { parser: "typescript" }, +); +const output = new URL("../src/deep-scan-defaults.ts", import.meta.url); +if (process.argv.includes("--check")) { + if ((await readFile(output, "utf8")).replaceAll("\r\n", "\n") !== contents) { + throw new Error("Deep scan defaults are out of date. Run pnpm build."); + } +} else { + await writeFile(output, contents); +} diff --git a/sdk/typescript/scripts/generate-project-config-schema.mjs b/sdk/typescript/scripts/generate-project-config-schema.mjs new file mode 100644 index 000000000..e160407be --- /dev/null +++ b/sdk/typescript/scripts/generate-project-config-schema.mjs @@ -0,0 +1,10 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { format } from "prettier"; +import { projectConfigJsonSchema } from "../dist/project-config-schema.js"; + +const directory = new URL("../schemas/", import.meta.url); +await mkdir(directory, { recursive: true }); +await writeFile( + new URL("project-config.schema.json", directory), + await format(JSON.stringify(projectConfigJsonSchema()), { parser: "json" }), +); diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index ca308aadf..07328bc72 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -26,11 +26,6 @@ import { type ThreadOptions, type TurnOptions, } from "@openai/codex-sdk"; -import { - parse as parseToml, - stringify as stringifyToml, - type TomlTable, -} from "smol-toml"; import { z } from "incur"; import { accountStatus, @@ -66,6 +61,20 @@ import { DeepScanProgressTracker, type DeepScanProgress, } from "./deep-progress.js"; +import { + deepScanOptions, + resolveDeepScanConfig, + writeDeepScanConfig, + type DeepScanSources, + type ResolvedDeepScanConfig, +} from "./deep-config.js"; +import { + SCAN_AUTH_MODES, + type DeepScanOptions, + type ScanAuthMode, +} from "./scan-settings.js"; +export { SCAN_AUTH_MODES } from "./scan-settings.js"; +export type { DeepScanOptions, ScanAuthMode } from "./scan-settings.js"; import { loadContract, readScanFile, @@ -215,14 +224,6 @@ interface PreparedSession { const DEEP_SCAN_CONFIG_PATH_ENVIRONMENT = "CODEX_SECURITY_DEEP_SCAN_CONFIG_PATH"; -export interface DeepScanOptions { - workers?: number; - subagents?: number; - stopAfterNoNew?: number; - maxDiscoveryRuns?: number; - maxTimeHours?: number; -} - export interface ScanOptions extends DeepScanOptions { /** Opt into a durable scan -> custom publication -> dedupe workflow. */ workflowId?: string; @@ -290,9 +291,6 @@ export interface ValidationResult { threadId: string | null; } -export const SCAN_AUTH_MODES = ["auto", "chatgpt", "api-key"] as const; -export type ScanAuthMode = (typeof SCAN_AUTH_MODES)[number]; - export type ScanAuthentication = | { method: "api_key"; @@ -359,12 +357,14 @@ export interface ScanPreflight extends DeepScanOptions { modelProvider?: string; reasoningEffort: string; maxCostUsd?: number; + deepScanSources?: DeepScanSources; } interface LocalScanInputs extends Omit { protectedRoot: string; stateDirectory: string; + deepScanConfiguration?: ResolvedDeepScanConfig; } export interface CodexSecurityMetadata { @@ -406,13 +406,6 @@ const SAFETY_IDENTIFIER_ENV = "CODEX_SAFETY_IDENTIFIER"; const PERSONAL_TRUSTED_ACCESS_URL = "https://chatgpt.com/cyber"; const ORGANIZATIONAL_TRUSTED_ACCESS_URL = "https://openai.com/form/enterprise-trusted-access-for-cyber/"; -const DEEP_SCAN_SETTINGS = [ - ["workers", "workers", 1], - ["subagents", "subagents", 0], - ["stopAfterNoNew", "stop_after_no_new", 1], - ["maxDiscoveryRuns", "max_discovery_runs", 1], - ["maxTimeHours", "max_time_hours", 0], -] as const; export class CodexSecurity { public readonly config: Readonly; public readonly metadata: CodexSecurityMetadata = { @@ -476,6 +469,7 @@ export class CodexSecurity { { ...options, outputDir: undefined, archiveExisting: false }, signal, ); + options = { ...options, ...local.deepScanConfiguration?.settings }; const workflow = new FindingWorkflow( workflowId, this.#dependencies.environment, @@ -720,7 +714,10 @@ export class CodexSecurity { repository: inputs.repository, target: inputs.target, mode: inputs.mode, - ...deepScanOptions(options), + ...inputs.deepScanConfiguration?.settings, + ...(inputs.deepScanConfiguration === undefined + ? {} + : { deepScanSources: inputs.deepScanConfiguration.sources }), ...(options.knowledgeBasePaths?.length ? { knowledgeBasePaths: options.knowledgeBasePaths } : {}), @@ -788,6 +785,7 @@ export class CodexSecurity { outputDir: requestedOutput, protectedRoot, stateDirectory, + deepScanConfiguration, } = await this.#validateLocalInputs(repository, options, signal); checkOpen(); let temporaryRoot: string | undefined; @@ -834,13 +832,11 @@ export class CodexSecurity { ? runtime.deepScanConfigPath ?? join(runtimeHome, "codex-security", "config.toml") : undefined; - if (deepScanConfigPath !== undefined) { - await prepareDeepScanConfig( - deepScanConfigPath, - this.#dependencies.environment, - options, - signal, - ); + if ( + deepScanConfigPath !== undefined && + deepScanConfiguration !== undefined + ) { + await writeDeepScanConfig(deepScanConfigPath, deepScanConfiguration); } checkOpen(); const scanOutputRoot = @@ -1039,8 +1035,10 @@ export class CodexSecurity { options.failureSeverity, knowledgeBase?.sources, options.maxCostUsd, - deepScanOptions(options), + deepScanConfiguration?.settings, + options.auth, ); + if (options.scanPrompt !== undefined) recipe["requiresScanPrompt"] = true; if (options.validationPrompt !== undefined) recipe["validationMode"] = "custom"; const workbenchOptions: WorkbenchCommandOptions = { @@ -2406,6 +2404,25 @@ export class CodexSecurity { outputDir: requestedOutput, protectedRoot, stateDirectory, + ...(mode === "deep" + ? { + deepScanConfiguration: await resolveDeepScanConfig( + options, + join( + expandHome( + environmentValue( + this.#dependencies.environment, + "CODEX_HOME", + ) ?? join(homedir(), ".codex"), + this.#dependencies.environment, + ), + "codex-security", + "config.toml", + ), + signal, + ), + } + : {}), }; } @@ -2538,94 +2555,6 @@ export async function listRepositoryFindings( return findings; } -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 (name === "maxTimeHours") { - if (!Number.isFinite(value) || value <= 0 || value > 96) { - throw new CodexSecurityError( - "Deep scan maxTimeHours must be a positive number no greater than 96.", - ); - } - } else 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( - destination: string, - environment: ProcessEnvironment, - options: DeepScanOptions, - signal: AbortSignal, -): Promise { - const ambientHome = expandHome( - environmentValue(environment, "CODEX_HOME") ?? join(homedir(), ".codex"), - environment, - ); - 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; - } - const sharedConfig = await sameExistingPath(source, destination); - const hasOverrides = Object.keys(overrides).length > 0; - if (existing === undefined && !hasOverrides) { - if (!sharedConfig) { - await rm(destination, { force: true }); - } - return; - } - if (sharedConfig && !hasOverrides) return; - await mkdir(dirname(destination), { recursive: true, mode: 0o700 }); - await writeFile( - destination, - stringifyToml({ - ...configured, - deep_scan: { ...existing, ...overrides }, - }), - { mode: 0o600, signal }, - ); -} - -async function sameExistingPath(left: string, right: string): Promise { - if (left === right) return true; - const [canonicalLeft, canonicalRight] = await Promise.all([ - realpath(left).catch(() => null), - realpath(right).catch(() => null), - ]); - return canonicalLeft !== null && canonicalLeft === canonicalRight; -} - export function createSecurity( config: CodexSecurityConfig = {}, ): CodexSecurity { @@ -3141,6 +3070,7 @@ function scanRecipe( knowledgeBasePaths?: string[], maxCostUsd?: number, deepScan?: DeepScanOptions, + auth?: ScanAuthMode, ): JsonObject { return { repository, @@ -3156,12 +3086,13 @@ function scanRecipe( ...(repositoryRevision === null ? {} : { repositoryRevision }), pluginVersion, config: preflightConfig, + ...(auth === undefined ? {} : { auth }), ...(failOnSeverity === undefined ? {} : { failOnSeverity }), ...(knowledgeBasePaths === undefined ? {} : { knowledgeBasePaths }), ...(maxCostUsd === undefined ? {} : { maxCostUsd }), ...(deepScan === undefined || Object.keys(deepScan).length === 0 ? {} - : { deepScan: { ...deepScan } }), + : { deepScan: { ...deepScan }, deepScanResolved: true }), }; } diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 33bf8aca9..2004907b6 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -165,12 +165,7 @@ import { type ScanProgress, type ScanWorkerStatus, } from "./worker-progress.js"; -import { - abortable, - DiffTarget, - type ScanMode, - type ScanTarget, -} from "./targets.js"; +import { abortable, DiffTarget, type ScanTarget } from "./targets.js"; import { resolveTrustedExecutable } from "./trusted-executable.js"; import { BUNDLED_PLUGIN_VERSION, @@ -183,6 +178,18 @@ import { VERSION, } from "./version.js"; +import { + loadProjectConfig, + resolveProjectConfig, + type ProjectConfigProvenance, + type ScanSettings, +} from "./project-config.js"; +import { + DEEP_SCAN_SETTINGS, + DeepScanSettingsSchema, + REPORTABLE_SEVERITIES, +} from "./scan-settings.js"; + const PROGRESS_REFRESH_MILLISECONDS = 1_000; const execFile = promisify(execFileCallback); const WINDOWS_NETWORK_PATH = /^[\\/]{2}/u; @@ -207,12 +214,6 @@ type Writable = Pick & { type SignalName = "SIGINT" | "SIGTERM"; type FailureSeverity = Exclude; -const REPORTABLE_SEVERITIES: readonly FailureSeverity[] = [ - "critical", - "high", - "medium", - "low", -]; const DISPLAY_SEVERITIES: readonly SeverityLevel[] = [ ...REPORTABLE_SEVERITIES, "informational", @@ -240,6 +241,8 @@ const EXPORT_DEFAULT_OUTPUTS = { sarif: "results.sarif", } as const; const VALUE_OPTIONS = new Set([ + "--config", + "-c", "--port", "--workflow-id", "--auth", @@ -850,36 +853,11 @@ function effortOption() { } 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."), - maxTimeHours: z - .number() - .positive() - .max(96) - .optional() - .describe("Maximum deep-scan discovery hours (default: 96; maximum: 96)."), + workers: DeepScanSettingsSchema.shape.workers, + subagents: DeepScanSettingsSchema.shape.subagents, + stopAfterNoNew: DeepScanSettingsSchema.shape.stopAfterNoNew, + maxDiscoveryRuns: DeepScanSettingsSchema.shape.maxDiscoveryRuns, + maxTimeHours: DeepScanSettingsSchema.shape.maxTimeHours, }; async function readPromptFiles( @@ -971,36 +949,23 @@ export function resolveCliPath(directory: string, value: string): string { return resolve(directory, expandHome(value)); } -interface ScanArguments extends DeepScanOptions { +interface ScanArguments extends ScanSettings { + projectConfig?: ProjectConfigProvenance; workflowId?: string; - auth?: ScanAuthMode; safetyIdentifier?: string; verbose?: boolean; repository?: string; - paths: string[]; - knowledgeBasePaths: string[]; - scanPromptFile?: string; - validationPromptFile?: string; postScanPromptFile?: string; - diff?: string; - workingTree: boolean; - head?: string; - base?: string; - mode: ScanMode; model?: string; effort?: ScanReasoningEffort; provider?: "openai" | "amazon-bedrock" | ExternalModelProvider; - outputDir?: string; archiveExisting: boolean; pluginPath?: string; pythonPath?: string; codex: string[]; - codexOverrides?: JsonObject; - failOnSeverity?: FailureSeverity; patch?: boolean; patchSeverity?: FailureSeverity; createPr?: boolean; - maxCostUsd?: number; headless?: boolean; dryRun: boolean; parentScanId?: string; @@ -2794,6 +2759,7 @@ export async function main( description: "Run a Codex Security scan.", destructive: true, mcp: false, + alias: { config: "c" }, args: z.object({ repository: z .string() @@ -2802,6 +2768,11 @@ export async function main( }), options: z .object({ + config: optionValue("--config") + .optional() + .describe( + "Load one YAML or JSON project file. No file is loaded by default.", + ), workflowId: optionValue("--workflow-id") .optional() .describe( @@ -2809,9 +2780,10 @@ export async function main( ), auth: z .enum(SCAN_AUTH_MODES) - .default("auto") + .optional() + .meta({ default: "auto" }) .describe( - "Select ChatGPT, OPENAI_API_KEY/CODEX_API_KEY, or automatic authentication.", + "Select ChatGPT, OPENAI_API_KEY/CODEX_API_KEY, or automatic authentication (default: auto).", ), verbose: z .boolean() @@ -2824,13 +2796,15 @@ export async function main( ), path: z .array(optionValue("--path")) - .default([]) + .optional() + .meta({ default: [] }) .describe( "Scan only PATH; repeat for multiple repository-relative paths.", ), knowledgeBase: z .array(optionValue("--knowledge-base")) - .default([]) + .optional() + .meta({ default: [] }) .describe( "Add security-context files or directories; repeat for multiple paths.", ), @@ -2850,7 +2824,8 @@ export async function main( .describe("Scan committed Git changes from BASE to --head."), workingTree: z .boolean() - .default(false) + .optional() + .meta({ default: false }) .describe("Scan staged and unstaged changes against --base."), head: optionValue("--head") .optional() @@ -2860,8 +2835,11 @@ export async function main( .describe("Git base ref for --working-tree (default: HEAD)."), mode: z .enum(["standard", "deep"]) - .default("standard") - .describe("Scan mode; deep supports repository and path targets."), + .optional() + .meta({ default: "standard" }) + .describe( + "Scan mode (default: standard); deep supports repository and path targets.", + ), ...DEEP_SCAN_OPTION_SCHEMAS, model: optionValue("--model") .optional() @@ -2920,30 +2898,15 @@ export async function main( }) .refine( (options) => - Number(options.path.length > 0) + + Number((options.path?.length ?? 0) > 0) + Number(options.diff !== undefined) + - Number(options.workingTree) <= + Number(options.workingTree === true) <= 1, { message: "--path, --diff, and --working-tree are mutually exclusive.", }, ) - .refine( - (options) => options.head === undefined || options.diff !== undefined, - { message: "--head requires --diff." }, - ) - .refine( - (options) => options.base === undefined || options.workingTree, - { - message: "--base requires --working-tree.", - }, - ) - .refine( - (options) => - !options.archiveExisting || options.outputDir !== undefined, - { message: "--archive-existing requires --output-dir." }, - ) .refine( (options) => options.patchSeverity === undefined || options.patch, { @@ -2955,19 +2918,13 @@ export async function main( }) .refine((options) => !options.patch || !options.dryRun, { message: "--patch cannot be combined with --dry-run.", - }) - .refine( - (options) => - options.mode === "deep" || - (options.workers === undefined && - options.subagents === undefined && - options.stopAfterNoNew === undefined && - options.maxDiscoveryRuns === undefined && - options.maxTimeHours === undefined), - { message: "Deep scan settings require --mode deep." }, - ), + }), examples: [ { args: { repository: "." } }, + { + args: { repository: "." }, + options: { config: "codex-security.yaml" }, + }, { args: { repository: "." }, options: { model: "gpt-5.6-terra" } }, { args: { repository: "." }, @@ -2993,48 +2950,80 @@ export async function main( exitCode = 2; return; } - const outcome = await runScan( - { - auth: options.auth, - workflowId: options.workflowId, - safetyIdentifier: options.safetyIdentifier, - verbose: options.verbose, - repository: args.repository, - paths: options.path, - knowledgeBasePaths: options.knowledgeBase, - scanPromptFile: options.scanPromptFile, - validationPromptFile: options.validationPromptFile, - postScanPromptFile: options.postScanPromptFile, - diff: options.diff, - workingTree: options.workingTree, - head: options.head, - base: options.base, - mode: options.mode, - workers: options.workers, - subagents: options.subagents, - stopAfterNoNew: options.stopAfterNoNew, - maxDiscoveryRuns: options.maxDiscoveryRuns, - maxTimeHours: options.maxTimeHours, - model: options.model, - effort: options.effort, - provider: options.provider, - outputDir: options.outputDir, - archiveExisting: options.archiveExisting, - pluginPath: options.pluginPath, - pythonPath: options.python, - codex: options.codex, - failOnSeverity: options.failOnSeverity, - patch: options.patch, - patchSeverity: options.patchSeverity, - createPr: options.createPr, - maxCostUsd: options.maxCost, - headless: options.headless, - dryRun: options.dryRun, - }, - errorOutput, - dependencies, - format !== "json" && format !== "jsonl", - ); + let outcome: ScanOutcome; + try { + const directory = dependencies.currentDirectory(); + const project = + options.config === undefined + ? undefined + : await loadProjectConfig(options.config, directory); + const { settings, provenance } = resolveProjectConfig( + project, + { + auth: options.auth, + paths: options.path, + knowledgeBasePaths: options.knowledgeBase, + scanPromptFile: options.scanPromptFile, + validationPromptFile: options.validationPromptFile, + diff: options.diff, + workingTree: options.workingTree, + head: options.head, + base: options.base, + mode: options.mode, + workers: options.workers, + subagents: options.subagents, + stopAfterNoNew: options.stopAfterNoNew, + maxDiscoveryRuns: options.maxDiscoveryRuns, + maxTimeHours: options.maxTimeHours, + outputDir: options.outputDir, + failOnSeverity: options.failOnSeverity, + maxCostUsd: options.maxCost, + codexOverrides: parseCodexOverrides( + options.codex, + options.model, + options.effort, + options.provider, + project?.input.codex, + ), + }, + directory, + ); + if (options.archiveExisting && settings.outputDir === undefined) { + throw new CodexSecurityError( + "--archive-existing requires --output-dir.", + ); + } + outcome = await runScan( + { + ...settings, + projectConfig: provenance, + workflowId: options.workflowId, + safetyIdentifier: options.safetyIdentifier, + verbose: options.verbose, + repository: args.repository, + postScanPromptFile: options.postScanPromptFile, + model: options.model, + effort: options.effort, + provider: options.provider, + archiveExisting: options.archiveExisting, + pluginPath: options.pluginPath, + pythonPath: options.python, + codex: options.codex, + patch: options.patch, + patchSeverity: options.patchSeverity, + createPr: options.createPr, + headless: options.headless, + dryRun: options.dryRun, + }, + errorOutput, + dependencies, + format !== "json" && format !== "jsonl", + ); + } catch (error) { + const message = errorMessage(error); + errorOutput.write(`${message}\n`); + outcome = { exitCode: 2, error: message }; + } exitCode = outcome.exitCode; if (outcome.error !== undefined) { return incurError({ @@ -4505,6 +4494,11 @@ function scanArgumentsFromRecipe( "This scan does not have a saved launch recipe.", ); } + if (recipe["requiresScanPrompt"] === true) { + throw new CodexSecurityError( + "This scan used additional instructions that are not retained. Start a new scan with --scan-prompt-file or --config to supply them again.", + ); + } if ( recipe["validationMode"] === "custom" && validationPromptFile === undefined @@ -4604,15 +4598,27 @@ function scanArgumentsFromRecipe( "The saved scan recipe contains an invalid cost limit.", ); } - const deepScan = z - .object(DEEP_SCAN_OPTION_SCHEMAS) - .optional() - .safeParse(recipe["deepScan"]); + const deepScan = DeepScanSettingsSchema.optional().safeParse( + recipe["deepScan"], + ); if (!deepScan.success) { throw new CodexSecurityError( "The saved scan recipe contains invalid deep scan settings.", ); } + if ( + recipe["deepScanResolved"] === true && + DEEP_SCAN_SETTINGS.some(([name]) => deepScan.data?.[name] === undefined) + ) { + throw new CodexSecurityError( + "The saved scan recipe is missing resolved deep scan settings.", + ); + } + const auth = z.enum(SCAN_AUTH_MODES).optional().safeParse(recipe["auth"]); + if (!auth.success) + throw new CodexSecurityError( + "The saved scan recipe contains an invalid authentication choice.", + ); if ( mode !== "deep" && deepScan.data !== undefined && @@ -4624,6 +4630,7 @@ function scanArgumentsFromRecipe( } return { repository, + auth: auth.data, paths, knowledgeBasePaths, validationPromptFile, @@ -6556,6 +6563,7 @@ async function executeScan( workers: arguments_.workers, subagents: arguments_.subagents, stopAfterNoNew: arguments_.stopAfterNoNew, + stopAfterConsecutiveErrors: arguments_.stopAfterConsecutiveErrors, maxDiscoveryRuns: arguments_.maxDiscoveryRuns, maxTimeHours: arguments_.maxTimeHours, outputDir: arguments_.outputDir, @@ -6882,7 +6890,29 @@ async function executeScan( verified: effectivePreflight.authentication.verified, }); progress?.stopTimer(); - return { exitCode: 0, data: { dryRun: true, ...effectivePreflight } }; + if (arguments_.projectConfig !== undefined) { + for (const [name] of DEEP_SCAN_SETTINGS) { + const source = preflight.deepScanSources?.[name]; + if (source === undefined || source === "override") continue; + const field = name === "subagents" ? "subagentsPerWorker" : name; + arguments_.projectConfig.sources[`scan.deep.${field}`] = source; + } + } + return { + exitCode: 0, + data: { + dryRun: true, + ...effectivePreflight, + ...(arguments_.projectConfig === undefined + ? {} + : { + projectConfig: arguments_.projectConfig, + scanPromptFile: arguments_.scanPromptFile, + validationPromptFile: arguments_.validationPromptFile, + failOnSeverity: arguments_.failOnSeverity, + }), + }, + }; } if (result === null) { diagnostic("scan.failed", { @@ -7447,6 +7477,7 @@ export function parseCodexOverrides( model?: string, effort?: ScanReasoningEffort, provider?: "openai" | "amazon-bedrock" | ExternalModelProvider, + defaults?: JsonObject, ): JsonObject { const result = Object.create(null) as JsonObject; if (model !== undefined) result["model"] = model; @@ -7521,7 +7552,8 @@ export function parseCodexOverrides( } if ( (isExternalModelProvider(provider) || provider === "amazon-bedrock") && - !("model" in result) + !("model" in result) && + defaults?.["model"] === undefined ) { throw new CodexSecurityError( `--model is required when using --provider ${provider}`, diff --git a/sdk/typescript/src/config.ts b/sdk/typescript/src/config.ts index 27cbddd1b..5948d0a77 100644 --- a/sdk/typescript/src/config.ts +++ b/sdk/typescript/src/config.ts @@ -338,6 +338,15 @@ function validateNativeMultiAgentV2Overrides(overrides: JsonObject): void { } } +export function mergeCodexOverrides( + base: JsonObject, + overrides: JsonObject, +): JsonObject { + validateOverrideKeys(base); + validateOverrideKeys(overrides); + return deepMerge(cloneJson(base), overrides); +} + function deepMerge(base: JsonObject, overrides: JsonObject): JsonObject { for (const [key, value] of Object.entries(overrides)) { const existing = Object.hasOwn(base, key) ? base[key] : undefined; diff --git a/sdk/typescript/src/deep-config.ts b/sdk/typescript/src/deep-config.ts new file mode 100644 index 000000000..af8876566 --- /dev/null +++ b/sdk/typescript/src/deep-config.ts @@ -0,0 +1,133 @@ +import { readFile, realpath } from "node:fs/promises"; +import { parse as parseToml, type TomlTable } from "smol-toml"; +import { writeCodexConfig, type JsonObject } from "./config.js"; +import { DEFAULT_DEEP_SCAN_SETTINGS } from "./deep-scan-defaults.js"; +import { CodexSecurityError } from "./errors.js"; +import { DEEP_SCAN_SETTINGS, type DeepScanOptions } from "./scan-settings.js"; +import type { ScanMode } from "./targets.js"; + +export type DeepScanSources = Record< + keyof DeepScanOptions, + "default" | "legacy" | "override" +>; +export interface ResolvedDeepScanConfig { + settings: Required; + sources: DeepScanSources; + source: string; + document: TomlTable; + hasOverrides: boolean; +} + +export function deepScanOptions( + options: DeepScanOptions & { mode?: ScanMode }, +): 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 (name === "maxTimeHours") { + if (!Number.isFinite(value) || value <= 0 || value > 96) { + throw new CodexSecurityError( + "Deep scan maxTimeHours must be a positive number no greater than 96.", + ); + } + } else 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; +} + +export async function resolveDeepScanConfig( + options: DeepScanOptions, + source: string, + signal?: AbortSignal, +): Promise { + const explicit = deepScanOptions({ ...options, mode: "deep" }); + let document: TomlTable = {}; + // Complete saved settings do not depend on today's ambient configuration. + if (!DEEP_SCAN_SETTINGS.every(([name]) => explicit[name] !== undefined)) { + try { + document = parseToml( + await readFile(source, { encoding: "utf8", signal }), + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw new CodexSecurityError( + `Cannot read Codex Security configuration at ${source}.`, + { cause: error }, + ); + } + } + } + const existing = document["deep_scan"]; + if ( + existing !== undefined && + (typeof existing !== "object" || + existing === null || + Array.isArray(existing) || + ![Object.prototype, null].includes(Object.getPrototypeOf(existing))) + ) { + throw new CodexSecurityError( + `Codex Security configuration [deep_scan] at ${source} must be a TOML table.`, + ); + } + const configured = (existing ?? {}) as TomlTable; + const keys = new Set(DEEP_SCAN_SETTINGS.map(([, key]) => key)); + const unknown = Object.keys(configured).filter((key) => !keys.has(key)); + if (unknown.length > 0) { + throw new CodexSecurityError( + `Unknown Codex Security Deep Scan configuration ${unknown.join(", ")} in ${source}.`, + ); + } + const values: Record = { ...DEFAULT_DEEP_SCAN_SETTINGS }; + const sources = {} as DeepScanSources; + for (const [name, key] of DEEP_SCAN_SETTINGS) { + if (Object.hasOwn(configured, key)) values[name] = configured[key]; + if (name === "workers" && values[name] === "auto") + values[name] = DEFAULT_DEEP_SCAN_SETTINGS.workers; + if (explicit[name] !== undefined) values[name] = explicit[name]; + sources[name] = + explicit[name] !== undefined + ? "override" + : Object.hasOwn(configured, key) + ? "legacy" + : "default"; + } + const settings = deepScanOptions({ + ...values, + mode: "deep", + } as DeepScanOptions & { mode: ScanMode }) as Required; + return { + settings, + sources, + source, + document, + hasOverrides: Object.keys(explicit).length > 0, + }; +} + +export async function writeDeepScanConfig( + destination: string, + resolved: ResolvedDeepScanConfig, +): Promise { + if (!resolved.hasOverrides) { + const [source, target] = await Promise.all([ + realpath(resolved.source).catch(() => null), + realpath(destination).catch(() => null), + ]); + if (source !== null && source === target) return; + } + await writeCodexConfig(destination, { + ...resolved.document, + deep_scan: Object.fromEntries( + DEEP_SCAN_SETTINGS.map(([name, key]) => [key, resolved.settings[name]]), + ), + } as JsonObject); +} diff --git a/sdk/typescript/src/deep-scan-defaults.ts b/sdk/typescript/src/deep-scan-defaults.ts new file mode 100644 index 000000000..57b167f63 --- /dev/null +++ b/sdk/typescript/src/deep-scan-defaults.ts @@ -0,0 +1,9 @@ +// Generated from the plugin deep_scan_defaults.json. Run pnpm build. +export const DEFAULT_DEEP_SCAN_SETTINGS = { + workers: 4, + subagents: 3, + stopAfterNoNew: 4, + stopAfterConsecutiveErrors: 3, + maxDiscoveryRuns: 40, + maxTimeHours: 96, +} as const; diff --git a/sdk/typescript/src/project-config-schema.ts b/sdk/typescript/src/project-config-schema.ts new file mode 100644 index 000000000..d9f203597 --- /dev/null +++ b/sdk/typescript/src/project-config-schema.ts @@ -0,0 +1,132 @@ +import { z } from "zod"; +import { + DeepScanSettingsSchema, + REPORTABLE_SEVERITIES, + SCAN_AUTH_MODES, +} from "./scan-settings.js"; + +const nonempty = z.string().min(1); + +export const ProjectScopeSchema = z.union([ + z.strictObject({ + paths: z + .array(nonempty) + .min(1) + .describe("Literal paths relative to the selected repository."), + }), + z.strictObject({ + diff: z.strictObject({ + base: nonempty, + head: nonempty.optional().meta({ default: "HEAD" }), + }), + }), + z.strictObject({ + workingTree: z.strictObject({ + base: nonempty.optional().meta({ default: "HEAD" }), + }), + }), +]); + +export const ProjectConfigInputSchema = z.strictObject({ + $schema: nonempty + .optional() + .describe( + "Editor schema URI or relative path. The CLI does not fetch or select a validator from this value.", + ), + auth: z.enum(SCAN_AUTH_MODES).optional().meta({ + default: "auto", + description: "Credential-source choice only; never a credential value.", + }), + scan: z + .strictObject({ + mode: z + .enum(["standard", "deep"]) + .optional() + .meta({ default: "standard" }), + scope: ProjectScopeSchema.optional().describe( + "One scope variant. Omit for the whole repository. Mode compatibility is checked after overrides.", + ), + knowledgeBase: z + .array(nonempty) + .optional() + .describe( + "Context files or directories, relative to this file. An empty list selects no additional context.", + ), + instructionsFile: nonempty + .optional() + .describe("Additional scan instructions, relative to this file."), + validationFile: nonempty + .optional() + .describe( + "Custom validation instructions, relative to this file; not supported in active deep scans.", + ), + deep: DeepScanSettingsSchema.omit({ subagents: true }) + .extend({ + subagentsPerWorker: DeepScanSettingsSchema.shape.subagents, + }) + .optional() + .describe( + "Deep defaults; a valid block may be retained while standard mode is selected.", + ), + }) + .optional(), + codex: z + .object({ + model: nonempty.optional(), + model_reasoning_effort: nonempty.optional(), + model_provider: nonempty.optional(), + }) + .catchall(z.json()) + .optional() + .describe( + "Native Codex overrides. Common key types are checked here; existing native and wrapper restrictions still apply.", + ), + limits: z + .strictObject({ + maxCostUsdPerScan: z + .number() + .positive() + .optional() + .describe( + "Estimated USD limit per launched scan attempt, not a total batch budget. Omit for no limit.", + ), + }) + .optional(), + policy: z + .strictObject({ + failOnSeverity: z + .enum(REPORTABLE_SEVERITIES) + .optional() + .describe( + "Exit threshold; does not filter retained findings. Omit for report-only behavior.", + ), + }) + .optional(), + output: z + .strictObject({ + directory: nonempty + .optional() + .describe( + "Artifact directory relative to this file; existing outside-worktree checks still apply.", + ), + }) + .optional(), +}); + +export type ProjectConfigInput = z.infer; +export type ProjectScope = z.infer; + +export function projectConfigJsonSchema() { + return { + ...z.toJSONSchema(ProjectConfigInputSchema, { + target: "draft-07", + io: "input", + unrepresentable: "throw", + }), + title: "Codex Security project configuration", + description: + "Input schema for explicitly selected YAML or JSON project files. Filesystem, active scan combinations, native configuration, and runtime availability are checked separately.", + $comment: + "Generated from ProjectConfigInputSchema. Defaults are annotations; apply defaults only after merging input layers.", + }; +} diff --git a/sdk/typescript/src/project-config.ts b/sdk/typescript/src/project-config.ts new file mode 100644 index 000000000..b9535eb96 --- /dev/null +++ b/sdk/typescript/src/project-config.ts @@ -0,0 +1,277 @@ +import { readFile } from "node:fs/promises"; +import { dirname, extname, resolve } from "node:path"; +import Ajv from "ajv"; +import { parseDocument } from "yaml"; +import { + DEFAULT_CODEX_CONFIG, + mergeCodexOverrides, + type JsonObject, +} from "./config.js"; +import { ConfigurationError } from "./errors.js"; +import { + projectConfigJsonSchema, + type ProjectConfigInput, + type ProjectScope, +} from "./project-config-schema.js"; +import { expandHome } from "./runtime.js"; +import { + DEEP_SCAN_SETTINGS, + type DeepScanOptions, + type ScanAuthMode, +} from "./scan-settings.js"; +import type { ScanMode } from "./targets.js"; +import type { SeverityLevel } from "./models.js"; + +const validateProjectConfig = new Ajv({ + allErrors: true, +}).compile(projectConfigJsonSchema()); + +export interface LoadedProjectConfig { + path: string; + input: ProjectConfigInput; +} + +export interface ScanSettings extends DeepScanOptions { + auth?: ScanAuthMode; + mode: ScanMode; + paths: string[]; + diff?: string; + workingTree: boolean; + base?: string; + head?: string; + knowledgeBasePaths: string[]; + scanPromptFile?: string; + validationPromptFile?: string; + outputDir?: string; + failOnSeverity?: Exclude; + maxCostUsd?: number; + codexOverrides?: JsonObject; +} + +export type ConfigurationSource = "default" | "legacy" | "project" | "cli"; +export interface ProjectConfigProvenance { + path: string; + sources: Record; +} + +export async function loadProjectConfig( + file: string, + directory = process.cwd(), +): Promise { + const path = resolve(directory, expandHome(file)); + const extension = extname(path).toLowerCase(); + if (![".yaml", ".yml", ".json"].includes(extension)) { + throw new ConfigurationError( + "Project configuration must be a .yaml, .yml, or .json file.", + ); + } + let text: string; + try { + text = await readFile(path, "utf8"); + } catch (error) { + throw new ConfigurationError( + `Cannot read project configuration at ${path}.`, + { cause: error }, + ); + } + let value: unknown; + try { + if (extension === ".json") { + value = JSON.parse(text); + } else { + const document = parseDocument(text, { prettyErrors: false }); + if (document.errors.length > 0) throw document.errors[0]; + value = document.toJS(); + } + } catch (error) { + throw new ConfigurationError( + `Cannot parse project configuration at ${path}.`, + { cause: error }, + ); + } + if (!validateProjectConfig(value)) { + const issues = validateProjectConfig + .errors!.map((issue) => { + const message = + issue.keyword === "additionalProperties" + ? `Unknown key ${issue.params["additionalProperty"]}.` + : issue.message; + return `${issue.instancePath || "configuration"}: ${message}`; + }) + .join("; "); + throw new ConfigurationError( + `Invalid project configuration at ${path}: ${issues}`, + ); + } + return { path, input: value }; +} + +export function resolveProjectConfig( + project: LoadedProjectConfig | undefined, + overrides: Partial, + directory: string, +): { settings: ScanSettings; provenance?: ProjectConfigProvenance } { + const file = project?.input; + const sources: Record = { + auth: "default", + "scan.mode": "default", + "scan.scope": "default", + "scan.knowledgeBase": "default", + }; + const choose = ( + key: string, + configured: T | undefined, + explicit: T | undefined, + ): T | undefined => { + if (explicit !== undefined) { + sources[key] = "cli"; + return explicit; + } + if (configured !== undefined) { + sources[key] = "project"; + return configured; + } + return undefined; + }; + const filePath = (value: string | undefined): string | undefined => + value === undefined + ? undefined + : resolve(dirname(project!.path), expandHome(value)); + const cliPath = (value: string | undefined): string | undefined => + value === undefined ? undefined : resolve(directory, expandHome(value)); + + const mode = + choose("scan.mode", file?.scan?.mode, overrides.mode) ?? "standard"; + if ( + mode !== "deep" && + DEEP_SCAN_SETTINGS.some(([name]) => overrides[name] !== undefined) + ) { + throw new ConfigurationError("Deep scan settings require --mode deep."); + } + const explicitScopes = + Number(!!overrides.paths?.length) + + Number(overrides.diff !== undefined) + + Number(overrides.workingTree === true); + if (explicitScopes > 1) + throw new ConfigurationError( + "--path, --diff, and --working-tree are mutually exclusive.", + ); + let scope: ProjectScope | undefined = file?.scan?.scope; + if (scope !== undefined) sources["scan.scope"] = "project"; + if (overrides.paths?.length) scope = { paths: overrides.paths }; + else if (overrides.diff !== undefined) + scope = { diff: { base: overrides.diff } }; + else if (overrides.workingTree === true) scope = { workingTree: {} }; + else if ( + overrides.workingTree === false && + scope !== undefined && + "workingTree" in scope + ) { + scope = undefined; + sources["scan.scope"] = "cli"; + } + if (explicitScopes > 0) sources["scan.scope"] = "cli"; + if (overrides.head !== undefined) { + if (scope === undefined || !("diff" in scope)) + throw new ConfigurationError("--head requires --diff."); + scope = { diff: { ...scope.diff, head: overrides.head } }; + sources["scan.scope.diff.head"] = "cli"; + } + if (overrides.base !== undefined) { + if (scope === undefined || !("workingTree" in scope)) + throw new ConfigurationError("--base requires --working-tree."); + scope = { workingTree: { base: overrides.base } }; + sources["scan.scope.workingTree.base"] = "cli"; + } + const configuredDeep = file?.scan?.deep; + const deep: DeepScanOptions = {}; + if (mode === "deep") { + for (const [name] of DEEP_SCAN_SETTINGS) { + const field = name === "subagents" ? "subagentsPerWorker" : name; + const value = choose( + `scan.deep.${field}`, + configuredDeep?.[field], + overrides[name], + ); + if (value !== undefined) deep[name] = value; + } + } + + const codexOverrides = mergeCodexOverrides( + file?.codex ?? {}, + overrides.codexOverrides ?? {}, + ); + const recordNativeSources = ( + value: JsonObject, + source: ConfigurationSource, + prefix = "codex", + ) => { + for (const [key, item] of Object.entries(value)) { + const path = `${prefix}.${key}`; + if (item !== null && typeof item === "object" && !Array.isArray(item)) { + delete sources[path]; + recordNativeSources(item, source, path); + } else { + for (const existing of Object.keys(sources)) { + if (existing.startsWith(`${path}.`)) delete sources[existing]; + } + sources[path] = source; + } + } + }; + recordNativeSources(DEFAULT_CODEX_CONFIG, "default"); + recordNativeSources(file?.codex ?? {}, "project"); + recordNativeSources(overrides.codexOverrides ?? {}, "cli"); + const knowledgeBasePaths = + choose( + "scan.knowledgeBase", + file?.scan?.knowledgeBase?.map((value) => filePath(value)!), + overrides.knowledgeBasePaths?.map((value) => cliPath(value)!), + ) ?? []; + const settings: ScanSettings = { + auth: choose("auth", file?.auth, overrides.auth) ?? "auto", + mode, + paths: scope !== undefined && "paths" in scope ? [...scope.paths] : [], + workingTree: scope !== undefined && "workingTree" in scope, + ...(scope !== undefined && "diff" in scope + ? { diff: scope.diff.base, head: scope.diff.head ?? "HEAD" } + : {}), + ...(scope !== undefined && "workingTree" in scope + ? { base: scope.workingTree.base ?? "HEAD" } + : {}), + knowledgeBasePaths, + scanPromptFile: choose( + "scan.instructionsFile", + filePath(file?.scan?.instructionsFile), + cliPath(overrides.scanPromptFile), + ), + validationPromptFile: choose( + "scan.validationFile", + filePath(file?.scan?.validationFile), + cliPath(overrides.validationPromptFile), + ), + outputDir: choose( + "output.directory", + filePath(file?.output?.directory), + cliPath(overrides.outputDir), + ), + failOnSeverity: choose( + "policy.failOnSeverity", + file?.policy?.failOnSeverity, + overrides.failOnSeverity, + ), + maxCostUsd: choose( + "limits.maxCostUsdPerScan", + file?.limits?.maxCostUsdPerScan, + overrides.maxCostUsd, + ), + codexOverrides, + ...deep, + }; + return { + settings, + ...(project === undefined + ? {} + : { provenance: { path: project.path, sources } }), + }; +} diff --git a/sdk/typescript/src/scan-settings.ts b/sdk/typescript/src/scan-settings.ts new file mode 100644 index 000000000..87d08ee1c --- /dev/null +++ b/sdk/typescript/src/scan-settings.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; +import { DEFAULT_DEEP_SCAN_SETTINGS } from "./deep-scan-defaults.js"; + +export const SCAN_AUTH_MODES = ["auto", "chatgpt", "api-key"] as const; +export type ScanAuthMode = (typeof SCAN_AUTH_MODES)[number]; +export const REPORTABLE_SEVERITIES = [ + "critical", + "high", + "medium", + "low", +] as const; + +export const DEEP_SCAN_SETTINGS = [ + ["workers", "workers", 1], + ["subagents", "subagents", 0], + ["stopAfterNoNew", "stop_after_no_new", 1], + ["stopAfterConsecutiveErrors", "stop_after_consecutive_errors", 1], + ["maxDiscoveryRuns", "max_discovery_runs", 1], + ["maxTimeHours", "max_time_hours", 0], +] as const; + +export const DeepScanSettingsSchema = z.strictObject({ + workers: z.number().int().positive().optional().meta({ + default: DEFAULT_DEEP_SCAN_SETTINGS.workers, + description: "Maximum concurrent deep-scan discovery workers.", + }), + subagents: z.number().int().nonnegative().optional().meta({ + default: DEFAULT_DEEP_SCAN_SETTINGS.subagents, + description: "Subagents available to each deep-scan worker. Zero is valid.", + }), + stopAfterNoNew: z.number().int().positive().optional().meta({ + default: DEFAULT_DEEP_SCAN_SETTINGS.stopAfterNoNew, + description: "Stop after this many runs find no new issues.", + }), + stopAfterConsecutiveErrors: z.number().int().positive().optional().meta({ + default: DEFAULT_DEEP_SCAN_SETTINGS.stopAfterConsecutiveErrors, + description: "Stop after this many consecutive discovery errors.", + }), + maxDiscoveryRuns: z.number().int().positive().optional().meta({ + default: DEFAULT_DEEP_SCAN_SETTINGS.maxDiscoveryRuns, + description: "Maximum deep-scan discovery runs.", + }), + maxTimeHours: z.number().positive().max(96).optional().meta({ + default: DEFAULT_DEEP_SCAN_SETTINGS.maxTimeHours, + description: + "Maximum deep-scan discovery hours (default: 96; maximum: 96).", + }), +}); + +export type DeepScanOptions = z.infer; diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index e258155f5..198af5c44 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -743,7 +743,10 @@ describe("CodexSecurity orchestration", () => { const client = new TestClient( { pythonPath: "/definitely/missing/python" }, { - environment: { OPENAI_API_KEY: "must-not-be-used" }, + environment: { + OPENAI_API_KEY: "must-not-be-used", + CODEX_HOME: join(root, "ambient"), + }, prepareRuntime: async () => { runtimeStarted = true; throw new Error("runtime should not initialize"); @@ -761,6 +764,20 @@ describe("CodexSecurity orchestration", () => { repository, target: { kind: "paths", paths: ["src"] }, mode: "deep", + workers: 4, + subagents: 3, + stopAfterNoNew: 4, + stopAfterConsecutiveErrors: 3, + maxDiscoveryRuns: 40, + maxTimeHours: 96, + deepScanSources: { + workers: "default", + subagents: "default", + stopAfterNoNew: "default", + stopAfterConsecutiveErrors: "default", + maxDiscoveryRuns: "default", + maxTimeHours: "default", + }, outputDir: output, authentication: { method: "api_key", @@ -2552,7 +2569,7 @@ describe("CodexSecurity orchestration", () => { await client.close(); }); - test("applies deep scan overrides over the user's existing settings", async () => { + test("resolves deep settings before runtime preparation and records the snapshot", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); const ambientHome = join(root, "ambient-home"); @@ -2581,7 +2598,13 @@ describe("CodexSecurity orchestration", () => { {}, { environment: { CODEX_HOME: ambientHome }, - prepareRuntime: async () => preparedRuntime(codexHome), + prepareRuntime: async () => { + await writeFile( + join(ambientHome, "codex-security", "config.toml"), + "[deep_scan]\nstop_after_no_new = 99\n", + ); + return preparedRuntime(codexHome); + }, resolvePluginPython: async () => "/managed/python", prepareOutputDir: async () => scanDir, repositoryRevision: async () => "deadbeef", @@ -2615,8 +2638,10 @@ describe("CodexSecurity orchestration", () => { await expect( client.run(repository, { mode: "deep", + auth: "auto", workers: 2, subagents: 0, + stopAfterConsecutiveErrors: 2, maxDiscoveryRuns: 10, maxTimeHours: 1.5, }), @@ -2628,15 +2653,20 @@ describe("CodexSecurity orchestration", () => { expect(configuration).toContain("workers = 2"); expect(configuration).toContain("subagents = 0"); expect(configuration).toContain("stop_after_no_new = 7"); + expect(configuration).toContain("stop_after_consecutive_errors = 2"); expect(configuration).toContain("max_discovery_runs = 10"); expect(configuration).toContain("max_time_hours = 1.5"); expect(configuration).toContain("[other]"); expect(configuration).toContain("enabled = true"); expect(recipe).toMatchObject({ mode: "deep", + auth: "auto", + deepScanResolved: true, deepScan: { workers: 2, subagents: 0, + stopAfterNoNew: 7, + stopAfterConsecutiveErrors: 2, maxDiscoveryRuns: 10, maxTimeHours: 1.5, }, @@ -2833,8 +2863,15 @@ describe("CodexSecurity orchestration", () => { await expect(client.run(repository, { mode: "deep" })).rejects.toThrow( "deep scan settings captured", ); - await expect(fsPromises.lstat(runtimeConfig)).rejects.toMatchObject({ - code: "ENOENT", + expect( + parseToml(await readFile(runtimeConfig, "utf8"))["deep_scan"], + ).toEqual({ + workers: 4, + subagents: 3, + stop_after_no_new: 4, + stop_after_consecutive_errors: 3, + max_discovery_runs: 40, + max_time_hours: 96, }); await writeFile(ambientConfig, "[deep_scan]\nworkers = 7\n"); diff --git a/sdk/typescript/tests-ts/cli-project-config.test.ts b/sdk/typescript/tests-ts/cli-project-config.test.ts new file mode 100644 index 000000000..e3fa70787 --- /dev/null +++ b/sdk/typescript/tests-ts/cli-project-config.test.ts @@ -0,0 +1,637 @@ +import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "bun:test"; +import { CodexSecurity, type ScanOptions } from "../src/api.js"; +import { main } from "../src/cli.js"; +import type { CodexSecurityConfig, JsonObject } from "../src/config.js"; +import type { ProjectConfigInput } from "../src/project-config-schema.js"; +import { capture, dependencies, fakeResult } from "./cli-fixtures.js"; + +const directories: string[] = []; +afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +async function fixture(input: ProjectConfigInput | string) { + const root = await realpath( + await mkdtemp(join(tmpdir(), "cli-project-config-")), + ); + directories.push(root); + const repository = join(root, "repository"); + const configDirectory = join(root, "settings"); + await mkdir(repository); + await mkdir(configDirectory); + await mkdir(join(repository, "src")); + await mkdir(join(repository, "lib")); + const config = join( + configDirectory, + typeof input === "string" ? "scan.yaml" : "scan.json", + ); + await writeFile( + config, + typeof input === "string" ? input : JSON.stringify(input), + ); + return { root, repository, configDirectory, config }; +} + +test("actual CLI parsing preserves file values when flags are absent", async () => { + const input = await fixture({ + auth: "api-key", + scan: { + mode: "deep", + scope: { paths: ["src"] }, + deep: { + workers: 8, + subagentsPerWorker: 0, + stopAfterConsecutiveErrors: 2, + }, + }, + limits: { maxCostUsdPerScan: 7 }, + policy: { failOnSeverity: "high" }, + codex: { model: "gpt-5.6-terra", model_reasoning_effort: "high" }, + }); + let selected: ScanOptions | undefined; + let native: CodexSecurityConfig | undefined; + let repository: string | undefined; + const stdout = capture(); + const stderr = capture(); + expect( + await main( + ["scan", "-c", input.config, "--json"], + stdout.stream, + stderr.stream, + dependencies({ + currentDirectory: input.repository, + environment: { OPENAI_API_KEY: "synthetic-test-key" }, + onConfig: (value) => { + native = value; + }, + onTurn: (target, value) => { + repository = target; + selected = value as ScanOptions; + }, + result: fakeResult(["high"]), + }), + ), + ).toBe(1); + expect(repository).toBe(input.repository); + expect(selected).toMatchObject({ + auth: "api-key", + mode: "deep", + target: ["src"], + workers: 8, + subagents: 0, + stopAfterConsecutiveErrors: 2, + maxCostUsd: 7, + failureSeverity: "high", + }); + expect(native?.codexOverrides).toMatchObject({ + model: "gpt-5.6-terra", + model_reasoning_effort: "high", + }); + expect(JSON.parse(stdout.text())).toMatchObject({ + manifest: { scan: { status: "completed" } }, + }); +}); + +test("CLI values override matching file values, including native objects and lists", async () => { + const input = await fixture({ + auth: "chatgpt", + scan: { + mode: "deep", + scope: { paths: ["src"] }, + knowledgeBase: ["file-context.md"], + deep: { workers: 8, subagentsPerWorker: 3 }, + }, + limits: { maxCostUsdPerScan: 7 }, + policy: { failOnSeverity: "high" }, + codex: { + model: "gpt-5.6-sol", + synthetic_setting: { enabled: true, names: ["first"] }, + }, + }); + let selected: ScanOptions | undefined; + let native: CodexSecurityConfig | undefined; + expect( + await main( + [ + "scan", + "--config", + input.config, + "--auth", + "auto", + "--path", + "lib", + "--knowledge-base", + "cli-context.md", + "--subagents", + "0", + "--workers", + "2", + "--max-cost", + "3", + "--fail-on-severity", + "low", + "--model", + "gpt-5.6-terra", + "--codex", + "synthetic_setting.enabled=false", + "--codex", + "synthetic_setting.names=[]", + "--json", + ], + capture().stream, + capture().stream, + dependencies({ + currentDirectory: input.repository, + onConfig: (value) => { + native = value; + }, + onTurn: (_target, value) => { + selected = value as ScanOptions; + }, + }), + ), + ).toBe(0); + expect(selected).toMatchObject({ + auth: "auto", + mode: "deep", + target: ["lib"], + knowledgeBasePaths: [join(input.repository, "cli-context.md")], + workers: 2, + subagents: 0, + maxCostUsd: 3, + failureSeverity: "low", + }); + expect(native?.codexOverrides).toMatchObject({ + model: "gpt-5.6-terra", + synthetic_setting: { enabled: false, names: [] }, + }); +}); + +test.each([ + [ + { paths: ["src"] }, + ["--diff", "HEAD"], + { kind: "refs", base: "HEAD", head: "HEAD" }, + ], + [{ diff: { base: "HEAD", head: "HEAD~1" } }, ["--path", "lib"], ["lib"]], + [ + { diff: { base: "HEAD", head: "HEAD~1" } }, + ["--head", "HEAD"], + { kind: "refs", base: "HEAD", head: "HEAD" }, + ], + [ + { workingTree: {} }, + ["--base", "HEAD~1"], + { kind: "working_tree", base: "HEAD~1" }, + ], + [{ workingTree: {} }, ["--no-working-tree"], "repository"], +] as const)( + "resolves scope selectors and dependent refs after merging (%j)", + async (scope, flags, target) => { + const input = await fixture({ + scan: { scope: structuredClone(scope) }, + } as ProjectConfigInput); + let selected: ScanOptions | undefined; + const stderr = capture(); + expect( + await main( + ["scan", "-c", input.config, ...flags, "--json"], + capture().stream, + stderr.stream, + dependencies({ + currentDirectory: input.repository, + onTurn: (_target, value) => { + selected = value as ScanOptions; + }, + }), + ), + ).toBe(0); + if (typeof target === "string" || Array.isArray(target)) + expect(selected?.target).toEqual(target); + else expect(selected?.target).toMatchObject(target); + }, +); + +test.each([ + [["--path", "src", "--diff", "HEAD"], "mutually exclusive"], + [["--head", "HEAD"], "--head requires --diff"], + [["--base", "HEAD"], "--base requires --working-tree"], + [["--workers", "2", "--mode", "standard"], "require --mode deep"], + [ + ["--model", "gpt-5.6-terra", "--codex", 'model="gpt-5.6-sol"'], + "--model conflicts", + ], +] as const)( + "rejects incompatible explicit overrides: %j", + async (flags, message) => { + const input = await fixture({}); + let initialized = false; + const stderr = capture(); + expect( + await main( + ["scan", "-c", input.config, ...flags, "--json"], + capture().stream, + stderr.stream, + dependencies({ + currentDirectory: input.repository, + onConfig: () => { + initialized = true; + }, + }), + ), + ).toBe(2); + expect(stderr.text()).toContain(message); + expect(initialized).toBe(false); + }, +); + +test("selecting standard mode leaves inactive file deep settings out of the active scan", async () => { + const input = await fixture({ + scan: { mode: "deep", deep: { workers: 8, stopAfterConsecutiveErrors: 2 } }, + }); + let selected: ScanOptions | undefined; + expect( + await main( + ["scan", "-c", input.config, "--mode", "standard", "--json"], + capture().stream, + capture().stream, + dependencies({ + currentDirectory: input.repository, + onTurn: (_target, value) => { + selected = value as ScanOptions; + }, + }), + ), + ).toBe(0); + expect(selected?.mode).toBe("standard"); + expect(selected?.workers).toBeUndefined(); + expect(selected?.stopAfterConsecutiveErrors).toBeUndefined(); +}); + +test("file prompts use the config directory and CLI prompt overrides use the invocation directory", async () => { + const input = await fixture({ + scan: { instructionsFile: "scan.md", validationFile: "validate.md" }, + }); + await writeFile( + join(input.configDirectory, "scan.md"), + "Synthetic file instructions.", + ); + await writeFile( + join(input.configDirectory, "validate.md"), + "Synthetic file validation.", + ); + await writeFile( + join(input.repository, "validate.md"), + "Synthetic CLI validation.", + ); + let selected: ScanOptions | undefined; + expect( + await main( + [ + "scan", + "-c", + input.config, + "--validation-prompt-file", + "validate.md", + "--json", + ], + capture().stream, + capture().stream, + dependencies({ + currentDirectory: input.repository, + onTurn: (_target, value) => { + selected = value as ScanOptions; + }, + }), + ), + ).toBe(0); + expect(selected).toMatchObject({ + scanPrompt: "Synthetic file instructions.", + validationPrompt: "Synthetic CLI validation.", + }); +}); + +test("an explicit file can supply the model required by a provider override", async () => { + const input = await fixture({ + codex: { model: "synthetic-model" }, + }); + let native: CodexSecurityConfig | undefined; + expect( + await main( + ["scan", "-c", input.config, "--provider", "amazon-bedrock", "--json"], + capture().stream, + capture().stream, + dependencies({ + currentDirectory: input.repository, + onConfig: (value) => { + native = value; + }, + }), + ), + ).toBe(0); + expect(native?.codexOverrides).toMatchObject({ + model: "synthetic-model", + model_provider: "amazon-bedrock", + }); +}); + +test.each([ + { argv: ["scan", "--help"] }, + { argv: ["scan", "--schema", "--json"] }, + { argv: ["info", "--json"] }, +])("malformed unselected files do not affect %j", async ({ argv }) => { + const input = await fixture("scan: ["); + await writeFile(join(input.repository, "codex-security.yaml"), "scan: ["); + let initialized = false; + expect( + await main( + [...argv], + capture().stream, + capture().stream, + dependencies({ + currentDirectory: input.repository, + onConfig: () => { + initialized = true; + }, + }), + ), + ).toBe(0); + expect(initialized).toBe(false); +}); + +test.each(["--help", "--schema"])( + "%s does not load even an explicitly selected invalid file", + async (flag) => { + const input = await fixture("scan: ["); + const stdout = capture(); + expect( + await main( + ["scan", "-c", input.config, flag, "--json"], + stdout.stream, + capture().stream, + dependencies({ currentDirectory: input.repository }), + ), + ).toBe(0); + if (flag === "--schema") + expect(JSON.parse(stdout.text())).toMatchObject({ + options: { properties: { config: { type: "string" } } }, + }); + }, +); + +test("a malformed selected file fails before constructing a client", async () => { + const input = await fixture("scan: ["); + let initialized = false; + const stdout = capture(); + const stderr = capture(); + expect( + await main( + ["scan", "-c", input.config, "--dry-run", "--json"], + stdout.stream, + stderr.stream, + dependencies({ + currentDirectory: input.repository, + onConfig: () => { + initialized = true; + }, + }), + ), + ).toBe(2); + expect(initialized).toBe(false); + expect(stderr.text()).toContain("Cannot parse project configuration"); + expect(stdout.text()).toBe(""); +}); + +test("dry-run uses the real SDK without initializing its runtime and reports provenance", async () => { + const input = await fixture({ + scan: { + mode: "deep", + scope: { paths: ["src"] }, + deep: { workers: 8, stopAfterConsecutiveErrors: 2 }, + }, + codex: { + profile: "review", + model: "gpt-5.6-sol", + profiles: { + review: { model: "gpt-5.6-terra", model_reasoning_effort: "high" }, + }, + }, + policy: { failOnSeverity: "high" }, + }); + const ambient = join(input.root, "ambient"); + await mkdir(join(ambient, "codex-security"), { recursive: true }); + await writeFile( + join(ambient, "codex-security", "config.toml"), + "[deep_scan]\nworkers = 6\nsubagents = 1\nstop_after_no_new = 5\n", + ); + const environment = { + codex_home: ambient, + CODEX_SECURITY_STATE_DIR: join(input.root, "state"), + }; + const stdout = capture(); + const stderr = capture(); + let initialized = false; + const deps = dependencies({ + currentDirectory: input.repository, + environment, + }); + deps.createSecurity = (config) => + new CodexSecurity( + config, + { + environment, + createCodex: () => { + initialized = true; + throw new Error("No inference in dry-run"); + }, + prepareRuntime: async () => { + initialized = true; + throw new Error("No runtime in dry-run"); + }, + }, + { surface: "cli" }, + ); + expect( + await main( + [ + "scan", + "-c", + input.config, + "--subagents", + "0", + "--model", + "gpt-5.6-sol", + "--dry-run", + "--json", + ], + stdout.stream, + stderr.stream, + deps, + ), + ).toBe(0); + expect(initialized).toBe(false); + expect(JSON.parse(stdout.text())).toMatchObject({ + dryRun: true, + repository: input.repository, + mode: "deep", + model: "gpt-5.6-terra", + reasoningEffort: "high", + target: { kind: "paths", paths: ["src"] }, + workers: 8, + subagents: 0, + stopAfterNoNew: 5, + stopAfterConsecutiveErrors: 2, + maxDiscoveryRuns: 40, + maxTimeHours: 96, + projectConfig: { + path: input.config, + sources: { + "scan.deep.workers": "project", + "scan.deep.subagentsPerWorker": "cli", + "scan.deep.stopAfterNoNew": "legacy", + "scan.deep.maxTimeHours": "default", + "codex.model": "cli", + "codex.profiles.review.model": "project", + }, + }, + failOnSeverity: "high", + }); +}); + +test.each([ + [{ output: { directory: "../repository/artifacts" } }, "outside"], + [{ codex: { plugins: {} } }, "plugin"], + [{ scan: { mode: "deep", validationFile: "validate.md" } }, "Deep"], +] as const)( + "project files retain active scan checks: %j", + async (config, message) => { + const input = await fixture(config as ProjectConfigInput); + await writeFile( + join(input.configDirectory, "validate.md"), + "Synthetic validation instructions.", + ); + const environment = { + CODEX_HOME: join(input.root, "ambient"), + CODEX_SECURITY_STATE_DIR: join(input.root, "state"), + }; + const stderr = capture(); + let initialized = false; + const deps = dependencies({ + currentDirectory: input.repository, + environment, + }); + deps.createSecurity = (configuration) => + new CodexSecurity( + configuration, + { + environment, + createCodex: () => { + initialized = true; + throw new Error("No inference"); + }, + prepareRuntime: async () => { + initialized = true; + throw new Error("No runtime"); + }, + }, + { surface: "cli" }, + ); + expect( + await main( + ["scan", "-c", input.config, "--dry-run", "--json"], + capture().stream, + stderr.stream, + deps, + ), + ).toBe(2); + expect(stderr.text().toLowerCase()).toContain(message.toLowerCase()); + expect(initialized).toBe(false); + }, +); + +test("rerun restores all saved deep settings and authentication without loading project files", async () => { + const input = await fixture("scan: ["); + const deep = { + workers: 7, + subagents: 0, + stopAfterNoNew: 5, + stopAfterConsecutiveErrors: 2, + maxDiscoveryRuns: 12, + maxTimeHours: 3, + }; + const recipe: JsonObject = { + repository: input.repository, + target: { kind: "repository", paths: [] }, + mode: "deep", + config: {}, + auth: "api-key", + deepScan: deep, + deepScanResolved: true, + }; + let selected: ScanOptions | undefined; + expect( + await main( + ["scans", "rerun", "saved", "--json"], + capture().stream, + capture().stream, + dependencies({ + currentDirectory: input.repository, + environment: { OPENAI_API_KEY: "synthetic-test-key" }, + onWorkbench: async () => ({ recipe }), + onTurn: (_target, value) => { + selected = value as ScanOptions; + }, + }), + ), + ).toBe(0); + expect(selected).toMatchObject({ + ...deep, + auth: "api-key", + parentScanId: "saved", + }); +}); + +test.each([ + [{ requiresScanPrompt: true }, "additional instructions"], + [ + { mode: "deep", deepScan: { workers: 7 }, deepScanResolved: true }, + "missing resolved deep scan settings", + ], +] as const)( + "rerun rejects an incomplete saved input: %j", + async (extra, message) => { + const input = await fixture({}); + const recipe: JsonObject = { + repository: input.repository, + target: { kind: "repository", paths: [] }, + mode: "standard", + config: {}, + ...extra, + }; + const stderr = capture(); + let ran = false; + expect( + await main( + ["scans", "rerun", "saved", "--json"], + capture().stream, + stderr.stream, + dependencies({ + currentDirectory: input.repository, + onWorkbench: async () => ({ recipe }), + onRun: () => { + ran = true; + }, + }), + ), + ).toBe(2); + expect(stderr.text()).toContain(message); + expect(ran).toBe(false); + }, +); diff --git a/sdk/typescript/tests-ts/deep-config.test.ts b/sdk/typescript/tests-ts/deep-config.test.ts new file mode 100644 index 000000000..849dc8e71 --- /dev/null +++ b/sdk/typescript/tests-ts/deep-config.test.ts @@ -0,0 +1,134 @@ +import { + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "bun:test"; +import { parse as parseToml } from "smol-toml"; +import { + resolveDeepScanConfig, + writeDeepScanConfig, +} from "../src/deep-config.js"; +import { DEFAULT_DEEP_SCAN_SETTINGS } from "../src/deep-scan-defaults.js"; + +const directories: string[] = []; +afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((path) => rm(path, { recursive: true, force: true })), + ); +}); +async function fixture(contents?: string) { + const root = await realpath(await mkdtemp(join(tmpdir(), "deep-config-"))); + directories.push(root); + const ambient = join(root, "ambient"); + await mkdir(join(ambient, "codex-security"), { recursive: true }); + const source = join(ambient, "codex-security", "config.toml"); + if (contents !== undefined) await writeFile(source, contents); + return { root, source }; +} + +test("resolves all six defaults without creating an ambient file", async () => { + const input = await fixture(); + const result = await resolveDeepScanConfig({}, input.source); + expect(result.settings).toEqual(DEFAULT_DEEP_SCAN_SETTINGS); + expect(new Set(Object.values(result.sources))).toEqual(new Set(["default"])); + await expect(readFile(input.source)).rejects.toMatchObject({ + code: "ENOENT", + }); +}); + +test("normalizes legacy auto and preserves explicit zero while merging", async () => { + const input = await fixture( + '[deep_scan]\nworkers = "auto"\nsubagents = 2\nstop_after_no_new = 6\nstop_after_consecutive_errors = 5\nmax_time_hours = 1.5\n', + ); + const result = await resolveDeepScanConfig( + { subagents: 0, stopAfterConsecutiveErrors: 2 }, + input.source, + ); + expect(result.settings).toEqual({ + workers: 4, + subagents: 0, + stopAfterNoNew: 6, + stopAfterConsecutiveErrors: 2, + maxDiscoveryRuns: 40, + maxTimeHours: 1.5, + }); + expect(result.sources).toEqual({ + workers: "legacy", + subagents: "override", + stopAfterNoNew: "legacy", + stopAfterConsecutiveErrors: "override", + maxDiscoveryRuns: "default", + maxTimeHours: "legacy", + }); +}); + +test("validates legacy values after matching explicit overrides", async () => { + const input = await fixture('[deep_scan]\nworkers = "invalid"\n'); + await expect(resolveDeepScanConfig({}, input.source)).rejects.toThrow( + "integer", + ); + expect( + (await resolveDeepScanConfig({ workers: 2 }, input.source)).settings + .workers, + ).toBe(2); +}); + +test.each([ + ["deep_scan = []\n", "TOML table"], + ["deep_scan = 2026-01-01\n", "TOML table"], + ["[deep_scan]\nworkers = true\n", "integer"], + ["[deep_scan]\nstop_after_consecutive_errors = 0\n", "integer"], + ["[deep_scan]\nmax_time_hours = 97\n", "no greater than 96"], + ["[deep_scan]\nworkres = 2\n", "Unknown"], + ["[deep_scan\n", "Cannot read"], +])("rejects invalid ambient settings: %s", async (contents, message) => { + const input = await fixture(contents); + await expect(resolveDeepScanConfig({}, input.source)).rejects.toThrow( + message, + ); +}); + +test("complete saved settings do not read a changed or invalid legacy file", async () => { + const input = await fixture("not valid TOML ["); + const saved = { + workers: 2, + subagents: 0, + stopAfterNoNew: 7, + stopAfterConsecutiveErrors: 2, + maxDiscoveryRuns: 12, + maxTimeHours: 1.5, + }; + const result = await resolveDeepScanConfig(saved, input.source); + expect(result.settings).toEqual(saved); + expect(new Set(Object.values(result.sources))).toEqual(new Set(["override"])); +}); + +test("runtime preparation writes the snapshot even if the ambient file changes", async () => { + const input = await fixture( + "[deep_scan]\nworkers = 7\nstop_after_no_new = 6\n[other]\nenabled = true\n", + ); + const result = await resolveDeepScanConfig({ subagents: 0 }, input.source); + await writeFile(input.source, "not valid TOML ["); + const destination = join(input.root, "runtime", "deep-scan.toml"); + await writeDeepScanConfig(destination, result); + expect(parseToml(await readFile(destination, "utf8"))).toEqual({ + deep_scan: { + workers: 7, + subagents: 0, + stop_after_no_new: 6, + stop_after_consecutive_errors: 3, + max_discovery_runs: 40, + max_time_hours: 96, + }, + other: { enabled: true }, + }); + expect(await readFile(input.source, "utf8")).toBe("not valid TOML ["); +}); diff --git a/sdk/typescript/tests-ts/project-config.test.ts b/sdk/typescript/tests-ts/project-config.test.ts new file mode 100644 index 000000000..dd574139a --- /dev/null +++ b/sdk/typescript/tests-ts/project-config.test.ts @@ -0,0 +1,325 @@ +import { mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "bun:test"; +import Ajv from "ajv"; +import { + loadProjectConfig, + resolveProjectConfig, +} from "../src/project-config.js"; +import { + ProjectConfigInputSchema, + projectConfigJsonSchema, + type ProjectConfigInput, +} from "../src/project-config-schema.js"; +import { DEFAULT_DEEP_SCAN_SETTINGS } from "../src/deep-scan-defaults.js"; + +const directories: string[] = []; +afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((path) => rm(path, { recursive: true, force: true })), + ); +}); +async function temporaryDirectory() { + const directory = await realpath( + await mkdtemp(join(tmpdir(), "project-config-")), + ); + directories.push(directory); + return directory; +} + +const cases: [string, unknown, boolean][] = [ + ["minimal file", {}, true], + [ + "standard path scope", + { scan: { mode: "standard", scope: { paths: ["src"] } } }, + true, + ], + [ + "zero subagents", + { + scan: { + mode: "deep", + deep: { workers: 4, subagentsPerWorker: 0, maxTimeHours: 96 }, + }, + }, + true, + ], + [ + "working tree with an absent base", + { scan: { scope: { workingTree: {} } } }, + true, + ], + [ + "diff with an absent head", + { scan: { scope: { diff: { base: "HEAD" } } } }, + true, + ], + ["empty context list", { scan: { knowledgeBase: [] } }, true], + [ + "editor metadata", + { $schema: "../schemas/project-config.schema.json" }, + true, + ], + [ + "native JSON passthrough", + { + codex: { synthetic_setting: { enabled: false, names: [], value: null } }, + }, + true, + ], + [ + "model availability is a later check", + { codex: { model: "synthetic-model" } }, + true, + ], + [ + "mode and scope may be overridden later", + { scan: { mode: "deep", scope: { diff: { base: "HEAD" } } } }, + true, + ], + [ + "custom validation may be overridden later", + { scan: { mode: "deep", validationFile: "validate.md" } }, + true, + ], + ["unknown wrapper key", { concurrency: 4 }, false], + ["unknown scan key", { scan: { workres: 4 } }, false], + [ + "repository selection is not file configuration", + { repository: "." }, + false, + ], + ["null is not a reset", { policy: null }, false], + ["empty scope", { scan: { scope: {} } }, false], + [ + "multiple scope variants", + { scan: { scope: { paths: ["src"], diff: { base: "HEAD" } } } }, + false, + ], + ["empty path list", { scan: { scope: { paths: [] } } }, false], + ["missing diff base", { scan: { scope: { diff: {} } } }, false], + ["zero workers", { scan: { deep: { workers: 0 } } }, false], + ["fractional workers", { scan: { deep: { workers: 1.5 } } }, false], + ["no string coercion", { scan: { deep: { workers: "4" } } }, false], + ["negative subagents", { scan: { deep: { subagentsPerWorker: -1 } } }, false], + [ + "hours above the existing maximum", + { scan: { deep: { maxTimeHours: 97 } } }, + false, + ], + ["nonpositive cost", { limits: { maxCostUsdPerScan: 0 } }, false], + ["incorrect native model type", { codex: { model: 42 } }, false], +]; + +describe("project configuration input contract", () => { + const validate = new Ajv({ strict: true, allErrors: true }).compile( + projectConfigJsonSchema(), + ); + + test.each(cases)( + "Zod and JSON Schema agree: %s", + (_name, input, accepted) => { + const original = structuredClone(input); + const parsed = ProjectConfigInputSchema.safeParse(input); + expect(parsed.success).toBe(accepted); + expect(validate(input)).toBe(accepted); + expect(input).toEqual(original); + if (parsed.success) expect(parsed.data).toEqual(original); + }, + ); + + test("the packaged schema and shared deep defaults are current", async () => { + expect( + JSON.parse( + await readFile( + new URL("../schemas/project-config.schema.json", import.meta.url), + "utf8", + ), + ), + ).toEqual(projectConfigJsonSchema()); + expect( + JSON.parse( + await readFile( + new URL( + "../../../plugins/codex-security/scripts/deep_scan_defaults.json", + import.meta.url, + ), + "utf8", + ), + ), + ).toEqual(DEFAULT_DEEP_SCAN_SETTINGS); + }); + + test("YAML and JSON load the same literal data without adding defaults", async () => { + const root = await temporaryDirectory(); + const yaml = join(root, "scan.yaml"); + const json = join(root, "scan.json"); + const input = { + scan: { deep: { subagentsPerWorker: 0 } }, + codex: { synthetic_setting: "${LITERAL_VALUE}" }, + } satisfies ProjectConfigInput; + await writeFile( + yaml, + "scan:\n deep:\n subagentsPerWorker: 0\ncodex:\n synthetic_setting: ${LITERAL_VALUE}\n", + ); + await writeFile(json, JSON.stringify(input)); + expect((await loadProjectConfig(yaml)).input).toEqual(input); + expect((await loadProjectConfig(json)).input).toEqual(input); + }); + + test.each([ + ["invalid.yaml", "scan: [\n"], + ["duplicate.yaml", "scan: {}\nscan: {}\n"], + ["stream.yaml", "scan: {}\n---\nscan: {}\n"], + ["invalid.json", '{"scan":'], + ["invalid.ts", "export default {};"], + ["unknown.yaml", "scan:\n workres: 2\n"], + ])("rejects an invalid selected file: %s", async (name, contents) => { + const root = await temporaryDirectory(); + const path = join(root, name); + await writeFile(path, contents); + await expect(loadProjectConfig(path)).rejects.toThrow(); + }); + + test("reports a missing selected file", async () => { + await expect( + loadProjectConfig("missing.yaml", await temporaryDirectory()), + ).rejects.toThrow("Cannot read project configuration"); + }); +}); + +describe("project configuration resolution", () => { + test("resolves paths according to their layer and replaces lists", async () => { + const root = await temporaryDirectory(); + const project = { + path: join(root, "settings", "scan.yaml"), + input: { + scan: { + scope: { paths: ["src"] }, + knowledgeBase: ["context.md"], + instructionsFile: "scan.md", + validationFile: "validate.md", + }, + output: { directory: "../artifacts" }, + } satisfies ProjectConfigInput, + }; + const { settings, provenance } = resolveProjectConfig( + project, + { + knowledgeBasePaths: ["cli-context.md"], + validationPromptFile: "cli-validate.md", + }, + join(root, "invocation"), + ); + expect(settings).toMatchObject({ + paths: ["src"], + knowledgeBasePaths: [join(root, "invocation", "cli-context.md")], + scanPromptFile: join(root, "settings", "scan.md"), + validationPromptFile: join(root, "invocation", "cli-validate.md"), + outputDir: join(root, "artifacts"), + }); + expect(provenance?.sources).toMatchObject({ + "scan.knowledgeBase": "cli", + "scan.instructionsFile": "project", + "scan.validationFile": "cli", + "output.directory": "project", + }); + expect(project.input.scan.knowledgeBase).toEqual(["context.md"]); + }); + + test("retains valid false and zero native values and native profile structure", async () => { + const root = await temporaryDirectory(); + const project = { + path: join(root, "scan.yaml"), + input: { + scan: { mode: "deep", deep: { subagentsPerWorker: 3, workers: 8 } }, + codex: { + profile: "review", + profiles: { + review: { model: "gpt-5.6-terra", model_reasoning_effort: "high" }, + }, + synthetic_setting: { enabled: true, count: 2, names: ["first"] }, + }, + } satisfies ProjectConfigInput, + }; + const { settings, provenance } = resolveProjectConfig( + project, + { + subagents: 0, + codexOverrides: { + model: "gpt-5.6-sol", + synthetic_setting: { enabled: false, count: 0, names: [] }, + }, + }, + root, + ); + expect(settings).toMatchObject({ + subagents: 0, + workers: 8, + codexOverrides: { + model: "gpt-5.6-sol", + profile: "review", + profiles: project.input.codex.profiles, + synthetic_setting: { enabled: false, count: 0, names: [] }, + }, + }); + expect(provenance?.sources).toMatchObject({ + "scan.deep.subagentsPerWorker": "cli", + "scan.deep.workers": "project", + "codex.model": "cli", + "codex.profiles.review.model": "project", + }); + }); + + test("ignores valid inactive deep defaults but rejects explicit deep CLI options in standard mode", async () => { + const root = await temporaryDirectory(); + const project = { + path: join(root, "scan.yaml"), + input: { + scan: { mode: "deep", deep: { workers: 8 } }, + } satisfies ProjectConfigInput, + }; + expect( + resolveProjectConfig(project, { mode: "standard" }, root).settings + .workers, + ).toBeUndefined(); + expect(() => + resolveProjectConfig(project, { mode: "standard", workers: 2 }, root), + ).toThrow("require --mode deep"); + }); + + test("keeps existing unsafe native-key protections before merging", async () => { + const root = await temporaryDirectory(); + for (const [filename, contents] of [ + ["scan.json", '{"codex":{"__proto__":{"syntheticPollution":true}}}'], + ["scan.yaml", "codex:\n __proto__:\n syntheticPollution: true\n"], + ] as const) { + await writeFile(join(root, filename), contents); + const project = await loadProjectConfig(filename, root); + expect(() => resolveProjectConfig(project, {}, root)).toThrow( + "Invalid Codex override key: __proto__.", + ); + } + expect( + ({} as Record)["syntheticPollution"], + ).toBeUndefined(); + }); + + test.each([ + '{"__proto__":{"synthetic":true}}', + '{"scan":{"__proto__":{"synthetic":true}}}', + ])( + "rejects reserved unknown wrapper keys without dropping them: %s", + async (contents) => { + const root = await temporaryDirectory(); + const path = join(root, "scan.json"); + await writeFile(path, contents); + await expect(loadProjectConfig(path)).rejects.toThrow( + "Unknown key __proto__.", + ); + }, + ); +}); diff --git a/sdk/typescript/tests-ts/skeleton.test.ts b/sdk/typescript/tests-ts/skeleton.test.ts index d450d9b15..d2224f9ec 100644 --- a/sdk/typescript/tests-ts/skeleton.test.ts +++ b/sdk/typescript/tests-ts/skeleton.test.ts @@ -319,9 +319,7 @@ describe("TypeScript package skeleton", () => { await readFile(new URL("../package.json", import.meta.url), "utf8"), ); - expect(packageJson.scripts.build).toBe( - "node --run clean && tsc -p tsconfig.build.json && node scripts/build-dashboard.mjs", - ); + expect(packageJson.scripts.build).not.toMatch(/\b(?:pnpm|npm|bun)\b/u); expect(packageJson.scripts["build:plugin"]).toBe( "node scripts/build-plugin.mjs", ); From e934a27a04ca703e856022956bb489f034fafa76 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 29 Aug 2026 00:12:19 -0700 Subject: [PATCH 02/12] refactor(cli): simplify project configuration and docs --- README.md | 8 +- docs/project-configuration-qa.md | 90 ---- docs/project-configuration.md | 239 +++++---- .../cli-and-project-configuration-review.md | 211 -------- .../cli-and-project-configuration.md | 486 ------------------ docs/proposals/configuration-schema.md | 197 ------- docs/proposals/examples/codex-security.json | 13 - docs/proposals/examples/codex-security.yaml | 17 - sdk/typescript/README.md | 19 +- sdk/typescript/src/api.ts | 4 +- sdk/typescript/src/cli.ts | 18 +- sdk/typescript/src/project-config.ts | 2 +- .../tests-ts/cli-project-config.test.ts | 2 +- 13 files changed, 129 insertions(+), 1177 deletions(-) delete mode 100644 docs/project-configuration-qa.md delete mode 100644 docs/proposals/cli-and-project-configuration-review.md delete mode 100644 docs/proposals/cli-and-project-configuration.md delete mode 100644 docs/proposals/configuration-schema.md delete mode 100644 docs/proposals/examples/codex-security.json delete mode 100644 docs/proposals/examples/codex-security.yaml diff --git a/README.md b/README.md index bf52431d4..b78f9abc4 100644 --- a/README.md +++ b/README.md @@ -90,9 +90,5 @@ codex-security scan . --provider fireworks --model accounts/fireworks/models/qwe **๐Ÿ‘‰๐Ÿ‘‰ See the [Codex Security documentation](https://learn.chatgpt.com/docs/security/cli)** for full documentation. -The [project configuration prototype](docs/project-configuration.md) supports explicit -YAML/JSON files in this working tree; it is not yet a released CLI feature. - -For the design and remaining proposals, see the [project configuration and CLI proposal](docs/proposals/cli-and-project-configuration.md), -its [Promptfoo implementation review](docs/proposals/cli-and-project-configuration-review.md), -and the [JSON Schema design](docs/proposals/configuration-schema.md). +See [project configuration](docs/project-configuration.md) for reusable YAML/JSON +settings, CLI overrides, and editor schema support. diff --git a/docs/project-configuration-qa.md b/docs/project-configuration-qa.md deleted file mode 100644 index 20d69f564..000000000 --- a/docs/project-configuration-qa.md +++ /dev/null @@ -1,90 +0,0 @@ -# Project configuration prototype: QA - -These checks cover the local, unreleased implementation described in the -[prototype guide](project-configuration.md). They do not certify a released -package or a live scan against a model. - -| Check | Result | -| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Full Bun suite | 2,136 passed, 36 skipped, zero failures across 112 files in both runs (seeds `12345` and `2460537364`). | -| Focused SDK regression | 243 passed, 3 skipped across five files. | -| New configuration coverage | 81 cases across the input/schema, CLI, and deep-settings tests. | -| Real CLI invocations | 63 passed on Node 22.15.1, 24.15.0, and 26.0.0. | -| Packaged SDK and CLI | Passed installed public-import and strict NodeNext type checks, CLI startup, credential locking, bundled Codex, MCP initialization, and nested-worker checks. | -| Package contents | 412 archive entries, including the project schema and 123 bundled plugin files. | -| Editor language services | YAML Language Server 1.21.0 and JSON Language Service 4.1.8 accepted the examples, reported unknown keys, and supplied completions without network schema requests. | -| Zod / JSON Schema agreement | 26 shared input cases, with no coercion, default insertion, or unknown-key stripping. | -| Python checks | 64 capability-profile and source-compatibility tests passed. | -| MCP suite | All 22 test scripts completed successfully. | -| Static checks | TypeScript/MCP type checks, SDK formatting, Ruff lint/format, and plugin source compatibility passed. | - -The CLI checks used isolated settings and synthetic fixtures. They exercised file -and CLI precedence, native profiles, context and output paths, scope replacement, -inactive deep settings, all six resolved deep settings, and errors for missing or -invalid selected files. Help and CLI schema inspection succeeded without loading -a selected missing file. Both documented examples and empty YAML/JSON configurations ran successfully. No scan state -or output directories were created by these dry runs. - -QA found and fixed several integration problems: - -- An absent working-tree boolean incorrectly triggered the scope-conflict check. - Explicit presence now controls scope selection. -- Inferred SDK types initially pulled CLI-only declarations into an installed - consumer. Shared schemas now import Zod directly; the strict installed-consumer - check passes without suppressing declaration errors. -- A schema URN caused editor services to resolve local references incorrectly. - The generated schema now uses its file location as its identity, and - its relative references resolve offline. -- The legacy TOML resolver needed to reject a date where a deep-settings table - was expected. Its existing environment-path behavior is retained, and runtime - preparation uses the resolved settings snapshot. -- Zod's cloned output discarded reserved native keys and accepted a reserved - unknown key in strict wrapper sections. File loading now uses the generated - schema with the SDK's existing Ajv validator, preserving native input for the - existing override checks. Regression tests load JSON and YAML files and reject - reserved unknown wrapper keys. - -Earlier full-suite attempts exposed an outdated build-command assertion and an -unused type import; both were corrected. Other failures required using a canonical -temporary directory on macOS and allowing the existing process-inspection test to -read process state. One intermediate run was stopped after existing process and -SQLite fixtures timed out. The isolated credential-lock test and the 44 -publication/target tests subsequently passed without changes to those fixtures. -The full suite passed again after removal of the project version field, both with seed `12345` and in a randomized order. - -The local environment used macOS arm64, Bun 1.3.14, Python 3.12.12, and Ruff -0.16.1. Python tests ran with unrelated environment-installed pytest plugins -disabled. The final full runs did not overlap package/MCP checks and prevented -idle sleep only for the duration of each command. - -To reproduce the repository checks with the required tool versions installed, -run from the SDK directory: - -```sh -pnpm run test --seed 12345 -pnpm run types -pnpm run format -pnpm run test -pnpm run test:mcp -mkdir -p /tmp/codex-security-package -pnpm pack --pack-destination /tmp/codex-security-package -pnpm run check:package /tmp/codex-security-package/openai-codex-security-0.1.23.tgz -``` - -On macOS, use a canonical temporary directory such as `TMPDIR=/private/tmp` for -the existing path-sensitive fixtures. The process-group test also needs local -process-inspection access. From the repository root: - -```sh -PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python -m pytest -q plugins/codex-security/tests/test_capability_profiles.py .github/scripts/test_check_plugin_source_compatibility.py -python -m ruff check --config plugins/codex-security/pyproject.toml .github/scripts/check_plugin_source_compatibility.py .github/scripts/test_check_plugin_source_compatibility.py plugins/codex-security -python -m ruff format --check --config plugins/codex-security/pyproject.toml .github/scripts/check_plugin_source_compatibility.py .github/scripts/test_check_plugin_source_compatibility.py plugins/codex-security -python .github/scripts/check_plugin_source_compatibility.py -``` - -No live inference, credential verification, Linux/Windows execution, or release -publication was performed. Editor testing used the actual language services, not -a graphical editor session. Full native Codex schema completion, automatic file -discovery, batch adoption, and complete input replay remain outside this -prototype. Existing conditional tests remain skipped where their conditions do -not apply. diff --git a/docs/project-configuration.md b/docs/project-configuration.md index 336aa9a66..1f5586b56 100644 --- a/docs/project-configuration.md +++ b/docs/project-configuration.md @@ -1,44 +1,25 @@ -# Project configuration prototype +# Project configuration -This working tree implements the first increment of the -[configuration proposal](proposals/cli-and-project-configuration.md). It is not a -released CLI feature. Build the local package before trying it: +Use `scan [repository] -c FILE` or `scan [repository] --config FILE` to load one +YAML or JSON file: ```sh -cd sdk/typescript -pnpm run build:plugin -pnpm run build -cd ../.. -node sdk/typescript/bin/codex-security.mjs scan . -c docs/examples/codex-security.yaml --dry-run --json +codex-security scan . -c codex-security.yaml --dry-run --json +codex-security scan . -c codex-security.json --model gpt-5.6-terra ``` -The [YAML example](examples/codex-security.yaml) and equivalent -[JSON example](examples/codex-security.json) select this repository's TypeScript -source. A dry run checks local inputs; it does not start Codex, verify credentials, -or establish that a model is available. Removing `--dry-run` starts a scan and may -incur model charges. - -The [QA report](project-configuration-qa.md) records the checks, fixes, and limits -of the local prototype. +The supported extensions are `.yaml`, `.yml`, and `.json`. Without `-c`, no file +is loaded, even if `codex-security.yaml` exists. Other commands and SDK `run()` +calls do not discover project files. The repository comes from the positional +argument or invocation directory; the file cannot select a different target. -## Select a file - -`scan [repository] -c FILE`, also spelled `--config FILE`, loads one `.yaml`, `.yml`, -or `.json` file. Without that option, no project file is loaded, even when a -`codex-security.yaml` exists. Other commands and direct SDK `run()` calls do not -discover project files. There are no new initialization, discovery, or inspection -commands in this increment. - -The repository comes from the positional argument or the invocation directory. -Moving the configuration file does not change the target. The initial format -does not accept `repository` or `repositories` keys. - -For an ordinary project with a `src` directory, a minimal configuration is: +For a project with a `src` directory: ```yaml scan: scope: paths: [src] + knowledgeBase: [SECURITY.md, docs/architecture.md] codex: model: gpt-5.6-sol model_reasoning_effort: xhigh @@ -46,45 +27,75 @@ policy: failOnSeverity: high ``` -All settings are optional; `{}` uses the existing defaults. The file configures -settings, not automatic actions: -patching, PR creation, publication, post-scan actions, and machine-specific plugin -or Python selection remain explicit CLI/SDK inputs. +All settings are optional; `{}` uses the existing defaults. No `version` field +is needed. Unknown wrapper keys and invalid types are errors. Values are literal: +there is no executable configuration, environment interpolation, remote include, +or multiple-file merge. Wrapper `null` values do not reset settings. + +The [YAML example](examples/codex-security.yaml) and equivalent +[JSON example](examples/codex-security.json) select this repository's TypeScript +source. After [setting up the source checkout](../sdk/typescript/TESTING.md), try: + +```sh +cd sdk/typescript +pnpm run build:plugin +pnpm run build +cd ../.. +node sdk/typescript/bin/codex-security.mjs scan . -c docs/examples/codex-security.yaml --dry-run --json +``` + +## Settings + +| Field | Meaning | Default | +| -------------------------- | ---------------------------------------------------------------------------- | ---------------------------------- | +| `auth` | Credential source: `auto`, `chatgpt`, or `api-key`; never a credential value | `auto` | +| `scan.mode` | `standard` or `deep` | `standard` | +| `scan.scope` | Exactly one of `paths: [src]`, `diff: {base: HEAD}`, or `workingTree: {}` | Whole repository | +| `scan.knowledgeBase` | Context files or directories | Empty list | +| `scan.instructionsFile` | Additional scan instructions | Unset | +| `scan.validationFile` | Custom validation instructions; incompatible with active deep mode | Built-in validation | +| `scan.deep` | Deep discovery settings shown below | Existing deep defaults | +| `codex` | Native Codex settings and profiles | Existing isolated configuration | +| `limits.maxCostUsdPerScan` | Estimated USD limit for one scan attempt | No limit | +| `policy.failOnSeverity` | Exit threshold: `critical`, `high`, `medium`, or `low` | Report-only | +| `output.directory` | Artifact directory outside the scanned Git worktree | Existing private artifact location | + +The file configures scan settings. Patching, PR creation, publication, post-scan +actions, and machine-specific plugin or Python selection remain explicit CLI/SDK +inputs. ## Overrides and paths -Built-in defaults and applicable legacy deep settings are followed by the project -file, then explicitly supplied CLI values. Parsing preserves absent values; schema -defaults are documentation hints. Lists are replaced, not concatenated. +Settings apply in this order: built-in defaults, applicable legacy deep settings, +the project file, then explicitly supplied CLI values. Schema defaults are editor +hints; parsing does not insert them. Lists are replaced, not concatenated. -| Path or setting | Resolution | -| ----------------------------------------------------------------------------------- | ------------------------------- | -| Repository positional argument | Invocation directory | -| File `scan.scope.paths` | Selected repository | -| File `scan.knowledgeBase`, `instructionsFile`, `validationFile`, `output.directory` | Configuration file's directory | -| CLI context, prompt, and output paths | Invocation directory | -| Native values under `codex` | Existing native Codex semantics | +| Path | Relative to | +| ------------------------------------------------------- | ------------------------------ | +| Repository positional argument | Invocation directory | +| File `scan.scope.paths` | Selected repository | +| File context, instruction, validation, and output paths | Configuration file's directory | +| CLI context, prompt, and output paths | Invocation directory | +| Native values under `codex` | Existing native Codex rules | For example, `--knowledge-base context.md` replaces the file's entire context list -and resolves `context.md` from the invocation directory. Existing regular-file, -protected-path, credential, and outside-worktree output checks still apply. - -A scope selector replaces the file's scope variant. `--diff HEAD` discards file -paths; `--path src` discards a configured diff. A dependent `--head` can refine a -file diff, and `--base` can refine a file working-tree scope. Contradictory explicit -scope selectors fail. The existing `--no-working-tree` disables a configured -working-tree scope; it does not clear a file's path or committed-diff scope. - -There is no general CLI reset for file context, severity policy, cost limit, or -scope. Edit the file, select a different file, or omit `-c`. An empty context list -is valid; `null` is not a wrapper reset operator. - -Native objects merge using the existing configuration code. Duplicate native -assignments within the CLI layer remain errors; overriding a file value is valid. -A selected native profile can still take precedence over root model/effort values, -including convenience flags. `--provider openai` retains its existing behavior; -it does not clear a file's native provider selection. This prototype does not -change those compatibility rules. +and resolves from the invocation directory. Existing regular-file, protected-path, +credential, and outside-worktree output checks still apply. + +A CLI scope selector replaces the file's scope variant: `--diff HEAD` discards +configured paths, and `--path src` discards a configured diff. `--head` can refine a +file diff; `--base` can refine a file working-tree scope. Contradictory explicit +selectors fail. `--no-working-tree` disables a configured working-tree scope but +does not clear a path or committed-diff scope. + +There is no general CLI reset for configured context, policy, cost limit, or scope. +Edit the file, select another file, or omit `-c`. An empty context list is valid. + +Native objects merge using the existing configuration code. Duplicate native CLI +assignments remain errors; overriding a file value is valid. Selected native +profiles can still override root model/effort values, including convenience flags. +`--provider openai` retains its existing behavior and does not clear a native +provider selected by the file. ## Deep settings and limits @@ -102,55 +113,43 @@ limits: maxCostUsdPerScan: 10 ``` -The deep values above are the existing defaults, now shared with the Python -plugin. The cost limit is illustrative, not an estimate. Legacy user -`[deep_scan]` TOML remains supported, including `workers = "auto"`. The file and -CLI can override individual values. All six effective values are resolved before -runtime preparation, written to the runtime configuration, and recorded in new -recipes. A complete saved set does not depend on the current legacy file. +These deep settings show the existing defaults, shared with the Python plugin. +The cost limit is illustrative. Legacy user `[deep_scan]` TOML remains supported, +including `workers = "auto"`. File and CLI values override individual settings. +All six effective values are resolved before runtime preparation and saved in new +recipes. -A valid deep block can stay in a standard-mode file; it is inactive in the -standard scan. Explicit deep CLI options still require deep mode. Deep diff scans -and custom validation remain unsupported. Counts retain their existing bounds, -zero subagents is valid, and discovery time cannot exceed 96 hours. +A valid deep block can stay inactive in standard mode. Explicit deep CLI options +still require deep mode. Deep diff scans and custom validation remain unsupported. +Counts retain their existing bounds; zero subagents is valid, and discovery time +cannot exceed 96 hours. `maxCostUsdPerScan` has the same meaning as `--max-cost`: an estimated limit for one -scan attempt. In-flight work may exceed it. It does not cover a whole batch, -planning, matching, patching, or publication. `failOnSeverity` controls exit status -without removing findings from the result. +scan attempt. In-flight work may exceed it. It is not a total budget for a batch or +follow-up actions. `failOnSeverity` changes the exit status without filtering the +retained findings. -## Inspect the effective invocation +## Dry run and editor support -Use the existing `scan ... --dry-run --json`. Existing output fields remain, with -these additions: +`scan ... --dry-run --json` checks local inputs without starting Codex, verifying +credentials, or establishing model availability. Removing `--dry-run` starts a +scan and may incur model charges. Deep preflight shows all six effective settings +and `deepScanSources`, including when no project file was selected; invalid +applicable legacy settings now fail before runtime startup. -- Deep preflight reports all six effective deep values and `deepScanSources` - (`default`, `legacy`, or `override`). This also applies without `-c` and can - reject invalid applicable legacy settings earlier than before. -- With `-c`, `projectConfig.path` identifies the selected file and - `projectConfig.sources` identifies the origin of merged settings. Native entries - describe origins of native keys; native profile selection still determines the - effective model/effort shown at the top level. -- Selected instruction/validation file paths and the severity policy are included - when configured. Raw native configuration and credential values are not dumped. +With `-c`, the output also includes `projectConfig.path`, per-setting sources, +selected instruction/validation file paths, and the finding policy. Native sources +identify which layer supplied a key; profile selection still determines the +effective model and effort. Raw native configuration and credentials are not dumped. -Help, version, and CLI schema inspection do not load project files. A selected -missing, malformed, or invalid file exits `2`. Scan exit codes remain `0` for -completion without a policy failure, `1` for the configured finding threshold, -and `2` for failed, invalid, incomplete, or interrupted scans. - -## JSON Schema and editors +Help, version, and command schema output do not load project files. Missing, +malformed, or invalid selected files exit `2`. Scan exit codes remain `0` for +completion without a policy failure, `1` for the finding threshold, and `2` for +failed, invalid, incomplete, or interrupted scans. The generated [project schema](../sdk/typescript/schemas/project-config.schema.json) -comes from [one Zod input definition](../sdk/typescript/src/project-config-schema.ts) -and ships at `@openai/codex-security/schemas/project-config.schema.json`. -It is self-contained and uses Draft-07. The loader uses the SDK's existing Ajv -validator against that generated schema and retains the parsed input unchanged. -This avoids Zod's cloning behavior for reserved object keys. `pnpm build` -regenerates the schema; tests check the artifact, ordinary Zod/schema agreement, -and file validation at that reserved-key boundary. - -For a local package installation, a file at the project root can use: +is self-contained Draft-07 and ships in the npm package. A project-root YAML file +can use: ```yaml # yaml-language-server: $schema=./node_modules/@openai/codex-security/schemas/project-config.schema.json @@ -158,30 +157,22 @@ For a local package installation, a file at the project root can use: ``` JSON files can use a root `$schema` string with the same relative path. The CLI -treats it as editor metadata and uses the schema bundled with the installed -package; it never fetches a validator from the hint. No hosted schema URL is -required. - -The schema rejects unknown wrapper keys and invalid structural inputs without -coercion or inserted defaults. Checks requiring CLI overrides, files, Git, native -configuration, or runtime availability happen separately. Completion and typo -detection inside `codex` cover common model/provider fields only; other native -JSON settings retain existing checks. CLI `scan --schema --json` and result -artifact schemas remain separate contracts. +uses its bundled schema and never fetches the hint. Validation does not coerce +values, insert defaults, or discard unknown wrapper keys. Completion inside +`codex` covers common model/provider fields; other native JSON settings retain +existing checks. CLI `scan --schema --json` and result artifact schemas remain +separate contracts. ## Saved reruns -New recipes retain the resolved native configuration, scope, auth choice, finding +New recipes retain resolved native configuration, scope, auth choice, finding policy, cost limit, context paths, and all active deep settings. Reruns do not load -the project file again. Older partial deep recipes continue using applicable -legacy defaults for missing settings. +the project file again. Complete saved deep settings do not use the current legacy +file; older partial recipes continue using applicable defaults for missing values. Recipes do not snapshot source or context contents. Reruns use the current checkout -and current context files, subject to existing target checks. Additional scan -instructions are not retained: new recipes mark that requirement, and `scans -rerun` refuses to silently omit them. Start a new `scan -c FILE` or `scan ---scan-prompt-file FILE` to supply those instructions again. Custom validation -keeps its existing `scans rerun --validation-prompt-file FILE` requirement. - -Complete input replay, automatic discovery, SDK file-loading convenience APIs, -batch/component adoption, and full native-schema completion remain separate work. +and current context files. Additional scan instructions are not retained: a new +recipe records that requirement, and `scans rerun` refuses to omit them silently. +Start a new `scan -c FILE` or `scan --scan-prompt-file FILE` to supply them again. +Custom validation keeps its existing `scans rerun --validation-prompt-file FILE` +requirement. diff --git a/docs/proposals/cli-and-project-configuration-review.md b/docs/proposals/cli-and-project-configuration-review.md deleted file mode 100644 index df870f813..000000000 --- a/docs/proposals/cli-and-project-configuration-review.md +++ /dev/null @@ -1,211 +0,0 @@ -# Review: CLI and project configuration proposal - -The project-file direction is useful, but the first draft combined too many -changes and treated Promptfoo's configuration behavior as more uniform than it -is. The first implementation should prove that one explicitly selected file -resolves to the right scan. Initialization, automatic discovery, new inspection -commands, broader CLI restructuring, and complete input snapshots can follow. - -This review informs the revised -[proposal](cli-and-project-configuration.md). It records the pre-prototype audit -and does not change Promptfoo. The later [prototype](../project-configuration.md) -implements the explicit-file increment. - -The follow-up [alignment and JSON Schema design](configuration-schema.md) adds a -concrete draft schema and examines its relationship to runtime input validation, -native Codex settings, command introspection, and existing result schemas. - -The implementation review used Promptfoo **0.121.19** at -[ce4c59d](https://github.com/promptfoo/promptfoo/tree/ce4c59d93f055c9dfbbb66d841f681909089ddf0) -and Codex Security **0.1.23** at `0474146`. The Promptfoo source files cited below -matched that commit. Verification included 14 direct loader/parser probes and nine -source-CLI invocations under Node 24.18.0, including two built-in `echo` evaluations -with exported JSON. State, logs, fixtures, and outputs were isolated in temporary -directories. No live model calls, red-team generation, code scans, or publication -were performed. These are observations about the inspected source snapshot, not a -claim about every released build or platform. - -The most consequential changes to the first draft are: - -| Priority | Weakness in the first draft | Revision | -| -------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| High | A broad option-metadata refactor, precedence repair, five new CLI entrypoints/options, and stronger replay were bundled together. | Start with `scan -c FILE`, its schema and resolver, and the existing `scan --dry-run`. Do not make unrelated command cleanup a prerequisite. | -| High | โ€œCLI overrides winโ€ also promised to flatten native profiles, while the compatibility section promised unchanged argument behavior. | Distinguish merging the same configuration key from native profile selection. Preserve existing native semantics initially; review profile/alias behavior changes separately. | -| High | The planned dry run and recipe were described as fully resolved before checking the separate deep-settings runtime path. | Resolve legacy deep settings once and pass the same effective values to inspection, execution, and recipe persistence. | -| High | A saved configuration was treated as a reproducible scan. | Record resolved settings without rereading project YAML on rerun. Treat prompt/context snapshots, source restoration, and runtime pinning as additional work. | -| Medium | Repository selection, discovery, and two different kinds of configuration bypass complicated a settings file. | Keep the initial repository selection in `scan [repository]`. Add discovery later; a future `--no-config` should skip project YAML only. | -| Medium | The override contract mentioned empty lists and false values without explaining what existing flags can express. | Document replacement and clearing separately. Do not invent a flag for every reset operation. | - -Promptfoo's documented workflow remains a useful product reference: a project -file contains reusable settings, and explicit CLI arguments override defaults. -That is the contract described in its -[configuration reference](https://www.promptfoo.dev/docs/configuration/reference/). -The implementation needs a more qualified reading. - -| Promptfoo path | What the implementation does | Lesson for this proposal | -| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | -| Evaluation | Startup loads a discovered config before registering Commander options. Execution later reads explicit files, combines them, resolves resources, and applies runtime options. | Preserve the distinction between absent arguments and parser-generated defaults. | -| Red-team run | Generates a `redteam.yaml`, then evaluates it through another adapter. The evaluation call explicitly supplies `cache: true` and `write: true`; generation and evaluation have their own option handling. | Shared configuration does not mean every orchestration phase has identical controls. | -| Code scanning | Uses a separate small YAML schema, loader, and CLI merge. With no config path, its loader returns defaults without discovering a file. | This narrower adapter is a closer starting point for a thin wrapper than the entire eval loader. | - -Sources: [CLI startup](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/main.ts#L54-L80), -[red-team orchestration](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/redteam/shared.ts#L95-L160), -and [code-scan loader](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/codeScan/config/loader.ts#L80-L138). -The red-team behavior was traced, not executed. - -**Precedence needs to survive both the parser and the schema.** The eval command -uses discovered values as Commander defaults for options such as cache and table -output. Its action then parses those options through a Zod schema. The inherited -delay schema supplies zero even when the user did not pass `--delay`. Later -nullish-coalescing expressions cannot tell those generated values from explicit -arguments. See [command registration](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/commands/eval.ts#L93-L150), -[command schema](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/types/index.ts#L93-L121), -[action schema](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/node/doEval.ts#L76-L90), -and [runtime selection](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/node/doEval.ts#L527-L558). - -The probes used a synthetic file containing: - -```yaml -prompts: ["{{value}}"] -providers: [echo] -tests: - - vars: - value: synthetic -sharing: false -commandLineOptions: - cache: false - table: false - delay: 17 - maxConcurrency: 2 -``` - -| Probe | Observed result | -| ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| Parse an explicit `eval -c FILE` with no runtime flags and no discovered config | Commander supplies `cache: true` and `table: true`; schema parsing adds `delay: 0`. The loaded file still contains false/false/17. | -| Discover false cache/table defaults, then select another file containing true values with `-c` | The resolved file is the explicit file, but the earlier false parser defaults survive. | -| Run the real source CLI with file table=false/delay=17, plus `--no-cache --no-write --no-share` | A table is printed. Exported runtime options omit delay and retain concurrency 2. | -| Add explicit `--no-table --delay 17` to that run | The table disappears; exported runtime options contain delay 17 and concurrency 1. Both echo evaluations succeed with no errors or model cost. | - -The cache observations above are parser/resolver probes; actual evaluations -explicitly disabled caching. This distinction matters when interpreting the -evidence. - -Existing [evaluate-option tests](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/test/commands/eval/evaluateOptions.test.ts#L83-L99) -call `doEval` directly with constructed option objects. They provide useful coverage -of runtime precedence, but that path does not exercise Commander defaults or the -action's schema parsing. Our acceptance checks need both resolver tests and a real -CLI invocation. Codex Security already has schema defaults for mode, auth, paths, -and other settings, so this is a concrete integration concern in -[its scan registration](../../sdk/typescript/src/cli.ts). - -**Loading must happen after command routing and explicit selection.** Promptfoo's -startup reads default configuration before parsing most commands. A malformed -`promptfooconfig.yaml` made `eval --help` fail and also prevented `validate config --c VALID_FILE` from reaching the selected file. `code-scans run --help` succeeded -because that command family has an explicit -[default-loading exception](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/mainUtils.ts#L104-L132). - -The proposal should avoid loading a file merely to build help or register -defaults. First route the command; then select the one file it actually uses. -An explicit `-c` must not read the discoverable file first. Loading should not -initialize scan history, authentication, a provider, or a runtime. This also keeps -unrelated commands usable when project YAML is invalid. - -**Schema validation and execution preparation are different operations.** -Promptfoo's [reader](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/util/config/load.ts#L328-L414) -dereferences configuration references and renders environment templates before -validation. It normalizes `commandLineOptions` with a failing validation path, but other schema -errors can be warnings while the original object is returned. A string -`evaluateOptions.maxConcurrency: many` was returned by `readConfig`; the full -validation command subsequently rejected it. A misspelled option was discarded, -and `validate config` reported success. The two namespaces also have different -numeric constraints: negative concurrency was accepted under `evaluateOptions` -and rejected under `commandLineOptions`. These probes do not establish how an -evaluation with negative concurrency would behave. - -The [validation command](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/commands/validate.ts#L470-L511) -calls the full resolver, which -[loads providers](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/util/config/load.ts#L937-L960). -A local provider fixture wrote a marker from its constructor during validation; -its inference method was never called. This is an execution-boundary observation, -not a claim that loading a trusted local provider is itself a security defect. - -For Codex Security, reject invalid wrapper keys and types in the file parser, -merge explicit overrides, and then validate the active scan's combinations. -Keep runtime/model availability checks separate. Generate editor JSON Schema from -the serializable input schema and test the same examples against both validators. -Promptfoo's [schema generator](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/scripts/generateJsonSchema.ts#L88-L130) -requires special handling for transforms and runtime-only values; generating a -schema does not automatically prove complete runtime parity. - -**Multiple files need field-specific semantics and origin tracking.** -Promptfoo's [combiner](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/util/config/load.ts#L521-L753) -does not perform one generic deep merge. It combines tests and extensions, -deduplicates providers, merges option objects, treats sharing=false specially, and -rebases some prompt references. The later -[resolver](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/util/config/load.ts#L815-L849) -still uses the first config's directory as a shared base. - -In a two-directory probe, each config's prompt loaded from its own directory, but -a `defaultTest` reference supplied by the second file loaded from the first -directory. A single glob expanding to both config files also raised a path-type -error in this fixture. The relevant lesson is to keep the first Codex Security -format to one file, without `extends`, globs for configuration selection, or -overlays. Normalize each wrapper-owned path while its origin is known. Native -Codex path values need their existing native rules, not a recursive path rewrite. - -**Persisted settings are useful, but they are not an immutable dependency set.** -Promptfoo saves configuration and resolved -[runtime options](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/node/doEval.ts#L773-L786). -Resume prefers those persisted runtime values and -[re-resolves the saved config](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/node/doEval.ts#L103-L132). -In a direct probe of that resolution primitive, saving a config, editing its -referenced prompt, and resolving the saved config again loaded the edited prompt. -This was not a full pause/resume test. It establishes why a saved file reference -alone cannot promise input replay. - -Codex Security has a more immediate gap: its -[preflight and recipe construction](../../sdk/typescript/src/api.ts) select explicit -deep options, while runtime preparation separately reads the legacy TOML. The -Python [deep-settings resolver](../../plugins/codex-security/scripts/deep_scan_config.py) -also supports `stop_after_consecutive_errors`, which is absent from the current -TypeScript `DEEP_SCAN_SETTINGS` adapter. Adding YAML above these paths does not -make them agree automatically. - -The first implementation should resolve the active settings once, including all -deep defaults, and retain those values in the recipe. Runtime preparation must -consume them instead of rediscovering configuration. Full prompt/context capture, -source restoration, and runtime-version pinning should have a separate retention -and compatibility design. Do not advertise deterministic reruns before that work. - -**Native Codex configuration needs an explicit compatibility boundary.** Current -[model/profile resolution](../../sdk/typescript/src/config.ts) lets a selected -profile's model and effort take precedence over root values. Current CLI alias -handling also rejects duplicate `--model`/`--codex model` values, and an explicit -`--provider openai` does not populate the native provider key in the same way as -the other provider choices. Earlier CLI preflight probes reproduced both behaviors; -they did not run native inference. - -The first draft's proposed profile flattening changes those semantics. It should -be reviewed and tested as a compatibility change, not hidden inside YAML merging. -A native `codex` block can also contain paths and tool configuration; calling the -wrapper file declarative does not make every native setting inert. Start with -explicit selection and preserve existing protections. Do not implement another -native profile engine or claim exhaustive validation of future Codex options. - -The resulting implementation boundary is small enough to describe precisely: - -```text -route command and handle help - -> select one explicit project file - -> parse file fields and preserve explicitly supplied CLI fields - -> resolve wrapper-owned paths and merge settings - -> apply existing native semantics and validate the active scan - -> share resolved settings with dry run, execution, and saved recipe -``` - -No generic configuration framework or complete CLI metadata rewrite is required. -SDK callers should opt into project-file loading. A follow-up can add discovery -and initialization once their absence does not block normal CLI use, and another -can adapt existing batch/component orchestration. Keep command renaming and full -replay separate from those increments. diff --git a/docs/proposals/cli-and-project-configuration.md b/docs/proposals/cli-and-project-configuration.md deleted file mode 100644 index 14cd66db4..000000000 --- a/docs/proposals/cli-and-project-configuration.md +++ /dev/null @@ -1,486 +0,0 @@ -# Proposal: project configuration and a consistent CLI - -**Status: proposal with a local prototype of the first increment.** The -[prototype guide](../project-configuration.md) describes implemented behavior and -limits. Discovery, initialization, other new commands, and broader restructuring -remain proposals. The prototype has not been released. - -Codex Security should let a team save its normal scan settings in -`codex-security.yaml`, inspect those settings, and run a scan without reconstructing -a long command. CLI arguments should select a target or override individual -settings. The CLI and SDK should resolve those settings through shared code and -continue using the existing Codex runtime and security plugin. - -YAML is the primary authoring format; an explicit JSON file should represent the -same settings and use the same input schema. The -[Promptfoo alignment and JSON Schema design](configuration-schema.md) describes -the shared workflow, deliberate differences, and a -[generated schema](../../sdk/typescript/schemas/project-config.schema.json) for review. - -The first increment is an explicitly selected project file for `scan`, shared -configuration resolution, the existing dry run, and recording the resolved scan -settings. Automatic discovery, initialization, and new inspection commands can -follow. Batch/component adoption, command renaming, and complete replay snapshots -are separate increments. The [implementation review](cli-and-project-configuration-review.md) -explains this narrower scope and the Promptfoo behaviors behind it. - -Today, configuration is spread across several inputs: - -| Input | Current responsibility | Problem to address | -| ------------------------------------ | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| `scan` arguments | Repository, scope, model, prompts, limits, output, and finding policy | Repeated commands are difficult to maintain and review. | -| `--codex KEY=VALUE` | Native Codex configuration overrides | Shell quoting and alias/profile precedence make common settings harder to inspect. | -| User `[deep_scan]` TOML | Deep discovery defaults | These settings need to participate in project configuration without becoming a competing source of defaults. | -| `bulk-scan` CSV and arguments | Repository inventory, revisions, retries, and outer concurrency | Supported settings differ from single scans. | -| `scan-components` JSON and arguments | Component planning, standard scans, and combined results | Its orchestration settings overlap with other commands but have different scope. | - -For example, `--workers` controls discovery workers on a deep scan, repositories on -a bulk scan, and components on a component scan. `--max-cost` is a per-scan or -per-attempt limit; it is not a total budget for a batch. Component planning and -matching are outside the component scan limit. The current -[CLI documentation](../../sdk/typescript/README.md#cli) and -[deep-scan configuration](../../sdk/typescript/README.md#configure-deep-scans) -remain the reference for supported behavior. - -Promptfoo provides a useful workflow precedent: a discoverable project file, -initialization, configuration validation, an editor schema, and CLI overrides. Its -[configuration reference](https://www.promptfoo.dev/docs/configuration/reference/) -and [CLI guide](https://www.promptfoo.dev/docs/usage/command-line/) describe that -workflow. Its eval, red-team, and code-scan implementations have different loading -and override paths, so they are not a single reference architecture. Codex Security -should give each setting one canonical location. It does not need both `commandLineOptions` and -`evaluateOptions`, executable configuration files, or Promptfoo's provider -abstraction. - -Keep three concepts separate throughout the design: - -| Concept | Choices | Existing constraints to preserve | -| ------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------- | -| Scope | Whole repository, paths, committed diff, working-tree diff | These select alternative targets. | -| Analysis mode | Standard or deep | Deep supports repository/path scopes and built-in validation. | -| Orchestration | One scan, components, repository batch | Components currently run standard scans. Batch retries apply per repository. | - -`bulk` and `components` should not become additional values of `scan.mode`. A list -of scoped paths should not silently turn into independent component scans. - -The first proposed workflow is: - -```sh -# Prototype commands using a locally built package; not yet released. -codex-security scan -c codex-security.yaml --dry-run --json -codex-security scan -c codex-security.yaml -codex-security scan . -c security/ci.yaml --model gpt-5.6-terra --effort high -``` - -The only initial new CLI option is `scan [repository] -c FILE` / `--config FILE`. -It selects one YAML or JSON file; the flag is absent by default. Without it, -existing commands do not load project files and retain their scan defaults. A missing or -invalid selected file exits with code `2`. The repository still comes from the -existing positional argument, defaulting to the invocation directory. The file -does not select a repository in this first format. - -The existing `scan ... --dry-run` inspects that invocation, including file values, -explicit CLI overrides, their sources, and local target checks. Keep existing -output fields and formats; document any additive fields for resolved deep settings -and project-file provenance. Configuration checks do not prove that authentication, -a provider, a model, or a Codex runtime will work. Checks that only Codex can perform -remain runtime checks. - -Initially, `--config` applies only to `scan`. -`bulk-scan` and `scan-components` should not advertise it until their adapters -implement the same resolution behavior. No project configuration is loaded by -finding, publication, authentication, or service commands as a side effect of this -increment. - -A project file could contain the following. Paths describe an illustrative project -with the file at its root; the cost limit is an example, not an estimate of scan -cost. - -```yaml -# Proposed codex-security.yaml - -scan: - mode: standard - scope: - paths: [src, packages] - knowledgeBase: - - SECURITY.md - - docs/architecture.md - instructionsFile: security/scan.md - -codex: - model: gpt-5.6-sol - model_reasoning_effort: xhigh - -limits: - maxCostUsdPerScan: 10 - -policy: - failOnSeverity: high -``` - -A file contains only the settings a team wants to change; `{}` uses the existing defaults. -Omitting the output directory preserves the existing private artifact location. -`SECURITY.md` continues to describe security expectations; YAML holds execution -settings and the machine-readable exit policy. - -The optional root `$schema` key is editor metadata. YAML may instead use a -`yaml-language-server` comment, as in Promptfoo. The installed CLI uses its bundled -schema; it does not fetch or trust a validator named by the file. See the equivalent -[YAML](examples/codex-security.yaml) and -[JSON](examples/codex-security.json) examples with local generated-schema references. - -| Proposed field | Meaning | Default | -| -------------------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| `$schema` | Optional editor schema URI or relative path; not a runtime validator selector | Unset | -| `auth` | Existing `auto`, `chatgpt`, or `api-key` credential-source choice; never a credential value | `auto` | -| `scan.mode` | Existing standard/deep mode | `standard` | -| `scan.scope` | One scope variant, described below | Whole repository | -| `scan.knowledgeBase` | Context files or directories | Empty list | -| `scan.instructionsFile` | Additional scan instructions, equivalent to `--scan-prompt-file` | Unset | -| `scan.validationFile` | Custom validation, equivalent to `--validation-prompt-file` | Built-in validation; custom validation remains incompatible with deep mode | -| `scan.deep` | Deep discovery defaults | Existing deep defaults | -| `codex` | Native Codex configuration keys | Existing isolated Codex Security defaults | -| `limits.maxCostUsdPerScan` | Estimated USD limit for one launched scan attempt | No limit | -| `policy.failOnSeverity` | Exit threshold: `critical`, `high`, `medium`, or `low` | Report-only | -| `output.directory` | Artifact directory outside the scanned Git worktree | Existing private state/artifact location | - -`scan.scope` accepts exactly one of these alternatives: - -```yaml -# Scope example: repository-relative paths, not glob patterns. -paths: [src, packages] -``` - -```yaml -# Scope example: committed changes. A base is required; head defaults to HEAD. -diff: - base: origin/main - head: HEAD -``` - -```yaml -# Scope example: staged and unstaged changes. Base defaults to HEAD. -workingTree: - base: HEAD -``` - -Omit `scan.scope` to select the whole repository. Reuse existing target validation -and Git comparison semantics. This proposal does not add glob expansion, exclusion -patterns, or support for deep diff scans. - -The `codex` object uses the existing native vocabulary. `--model` maps to -`codex.model`, `--effort` to `codex.model_reasoning_effort`, and `--provider` to the -existing native provider selection/presets. Do not add another top-level YAML -`model` or `provider` field. Existing plugin ownership, multi-agent requirements, -credential handling, and permission restrictions continue to apply. YAML does not -make previously unsupported native overrides valid. Wrapper path rules below do -not reinterpret paths inside native configuration. - -Native profile selection remains a separate step from merging the same key across -layers. Today, a selected profile's model or effort can supersede a root value set -with `--model` or `--effort`. Preserve that behavior initially and show the effective -selection in dry-run output. Repairing convenience-flag/profile conflicts and the -special handling of `--provider openai` is a separate compatibility change; do not -promise universal CLI precedence before it is implemented and tested. The examples -use root native settings without profiles. - -A deep-scan file could instead include: - -```yaml -# Proposed codex-security.deep.yaml - -scan: - mode: deep - scope: - paths: [src] - deep: - workers: 4 - subagentsPerWorker: 3 - stopAfterNoNew: 4 - stopAfterConsecutiveErrors: 3 - maxDiscoveryRuns: 40 - maxTimeHours: 96 - -limits: - maxCostUsdPerScan: 25 -``` - -The values under `scan.deep` are the current deep defaults; the USD limit is -illustrative. `subagentsPerWorker` permits zero; worker and run counts retain their -positive-integer requirements. `maxTimeHours` retains the existing positive-number -and 96-hour maximum constraints. It limits discovery time, not all work in a larger -workflow. - -A file may retain deep defaults while selecting standard mode. Validate that block -when loading the file, but omit it from the active standard scan. Explicit -deep-only CLI options with standard mode should continue to fail. A selected custom -validation file with deep mode also remains an error. - -`limits.maxCostUsdPerScan` preserves the meaning of the existing per-scan limit. -In-flight requests can finish above the estimate-based threshold. It does not -promise a cap on component planning, cross-scan matching, retries across an entire -batch, or subsequent patching and publication commands. A total-run limit should -be a separate feature with accounting for every included model operation. - -The resolution rules should be part of the public contract: - -1. For a new scan, apply built-in defaults, applicable legacy user deep settings, - the selected project file, and explicitly supplied CLI values, in that order. - This precedence applies to the same setting/key; native profile selection keeps - its existing semantics. Credentials, executable discovery, state paths, and - integration environment defaults retain their independent rules. -2. Route commands and handle help/schema requests before reading project files. - Load exactly the file selected by `-c`, supporting `.yaml`, `.yml`, and `.json` - as the same input contract. Do not discover another file first or interpret the - path as a glob. Parsing a file must - not initialize authentication, providers, scan history, or the Codex runtime. -3. Resolve the CLI repository relative to the invocation directory, as today. Keep - repository selection out of the initial project schema. A file in another - directory does not change the selected repository. -4. Resolve project-file context, instruction, validation, and output paths relative to the - config file. Resolve CLI file paths relative to the invocation directory. Scope - paths remain relative to the selected repository. Normalize these wrapper-owned - paths while their origin is known; do not recursively rewrite native Codex - values. Preserve existing protected-root, output, and credential checks. -5. Keep absent CLI values absent through both argument parsing and schema parsing. - Making a default-bearing schema partial is not sufficient. Merge explicit - values using presence information or an input schema without defaults. Preserve - false, valid zero values, and supported empty lists; do not merge by truthiness. -6. Merge wrapper settings by field and replace lists. Repeated CLI `--path` or - `--knowledge-base` arguments replace the corresponding file list. A CLI scope - selector replaces the whole file variant: `--diff REF` must not inherit file - paths. Dependent flags such as `--head` refine the selected compatible scope - after merging; `--base` keeps its working-tree meaning. Contradictory explicit - scope selectors remain errors. -7. Map convenience flags to their existing native keys and retain duplicate/alias - checks within the CLI layer. Overriding a file value is not a duplicate argument. - Merge native objects using existing native configuration code. Do not flatten - selected profiles or silently change native resolution as part of adding a file - loader. -8. Reject unknown wrapper keys and invalid field types when parsing the file. - Validate cross-field combinations on the resolved active - scan, so CLI overrides can change the mode or scope. Reuse current checks inside - `codex`; leave validation that requires Codex to the runtime. - -For example, from a project root, `scan . -c security/codex-security.yaml` keeps -that project as the target. YAML instruction files resolve relative to `security/`; -`scan.scope.paths` resolves relative to the project root. CLI file overrides retain -their invocation-relative behavior. Verify these rules on Windows as well. - -Replacement is not the same as clearing. The existing CLI has no general reset for a configured severity policy, cost -limit, context list, or whole-repository scope. Its existing `--no-working-tree` -can disable a configured working-tree scope, but cannot clear another scope kind. Initially, edit the file or select an alternative file that omits those -settings; omitting `-c` skips all project settings. Do not claim that every YAML -setting has an expressible CLI reset. An empty context list is an empty list; -`null` is not a general reset operator for wrapper fields. A later discovery -increment can add the project-only bypass described below. - -Keep the first format limited to settings. Do not add embedded credentials, -executable JS/TS config files, wrapper shell hooks, environment templating, remote -includes, `extends`, multiple-file merging, or a second project-profile hierarchy. -Native Codex settings, including profiles and tool configuration, retain their -existing capabilities and checks; they are not made inert by being written in -YAML. Separate files selected with `-c` are sufficient for initial CI and deep-scan -workflows. Use existing environment-based credential mechanisms rather than -creating a new YAML environment loader. - -Patching, PR creation, publication, hook installation, archival, and post-scan -workflows remain explicit commands/options. Do not add automatic `patch: true` or -`publish: true` behavior to discovered YAML. Machine-specific `--python` and -`--plugin-path` overrides retain their existing explicit CLI/SDK/environment paths -rather than becoming project defaults. These choices keep the first increment -focused and preserve the distinction between repository data and authorization. - -The legacy deep TOML file needs a compatibility adapter, not an automatic rewrite: - -| Existing `[deep_scan]` key | Proposed project key | -| ------------------------------- | -------------------------------------- | -| `workers` | `scan.deep.workers` | -| `subagents` | `scan.deep.subagentsPerWorker` | -| `stop_after_no_new` | `scan.deep.stopAfterNoNew` | -| `stop_after_consecutive_errors` | `scan.deep.stopAfterConsecutiveErrors` | -| `max_discovery_runs` | `scan.deep.maxDiscoveryRuns` | -| `max_time_hours` | `scan.deep.maxTimeHours` | - -Continue accepting legacy `workers = "auto"` while reading old TOML and normalize it -to the existing value of four. Do not add that legacy value to the new format. -Project settings and explicit flags override user deep defaults. Reuse or generate -from the existing [deep configuration definitions](../../plugins/codex-security/scripts/deep_scan_config.py) -so the YAML loader does not introduce another hand-maintained set of defaults. - -Before the prototype, preflight and recipe construction selected explicit -TypeScript options, while runtime preparation separately read the legacy TOML. The Python resolver also supported -`stop_after_consecutive_errors`, which the TypeScript adapter did not expose. -Resolve all six effective settings once, validate them with the existing rules, -and pass that result to dry run, execution, and recipe persistence. The runtime -must consume those resolved values instead of rereading user/project configuration. -The prototype adds the YAML adapter; it does not require a new public CLI flag. -Resolving legacy deep settings earlier also changes deep dry runs without `-c`: -they can show those defaults and reject invalid legacy settings before execution. -Document that validation/output change explicitly; it does not change the actual -deep-scan defaults. - -The CLI should parse project input into typed settings, then resolve those settings -to the existing `CodexSecurityConfig` and scan inputs. Keep file parsing, field -resolution, local preflight, and runtime preparation separate. Do not build a -general configuration framework or consolidate all command metadata before the -single-scan path works. The relevant current implementation is in -[CLI registration](../../sdk/typescript/src/cli.ts), -[native configuration](../../sdk/typescript/src/config.ts), -[scan APIs and saved recipes](../../sdk/typescript/src/api.ts), and -[target resolution](../../sdk/typescript/src/targets.ts). - -Generate and package an editor JSON Schema with the first file-loader increment, -using a canonical serializable Zod input definition and explicit input-mode -conversion. Use Draft-07 for this project contract, matching Promptfoo; leave -existing Draft 2020-12 artifact contracts unchanged. Check the same valid and -invalid inputs against Zod and Ajv without inserting defaults, coercing types, or -stripping unknown wrapper keys. File loading uses the generated schema with the -existing Ajv engine, avoiding Zod's cloning behavior for reserved object keys. -The [schema design](configuration-schema.md) -defines the boundary between structural input checks and active-scan validation. - -Incur's command schemas and help remain available. `scan --schema --json` describes -CLI arguments/options, not the project-file schema. Native Codex configuration, -scan results, and recipes retain their separate contracts. There is no parser -replacement or new orchestration runtime in this proposal. - -SDK users should explicitly request file loading. Existing `CodexSecurity.run()` -calls must not start discovering YAML in the caller's current directory. Share -schemas and resolution logic between CLI and SDK while preserving explicit library -inputs and the current plugin configuration format. - -The initial recipe change should record resolved active settings, including values -that came from project files or legacy deep defaults, using existing private artifact -storage and credential exclusions. Build it from the resolved scan, not the raw -CLI options. Preserve existing revision and plugin metadata. `scans rerun` should -consume these saved settings without rediscovering current project files or changing the -recorded deep defaults. Older recipes need an explicit compatibility path. - -This does not promise complete input replay. Preserve existing requirements to -re-supply prompts that recipes do not retain; referenced context can still use -current file contents. State that limitation when rerunning. Recording a source -revision also does not make rerun restore that checkout. Report the source actually -scanned. A separate replay design should cover retaining prompt/context contents, -missing-input errors, source restoration, and runtime/plugin version pinning. A -path or hash alone is insufficient to reproduce an edited or deleted input. - -After the explicit-file path is proven, a separate increment can add the following -workflow conveniences: - -| Proposed syntax or behavior | Contract | Compatibility | -| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| Discover `codex-security.yaml` for `scan` | Look only in the invocation directory. No parent walk, cloned-repository discovery, or implicit file merging. | Deliberate new behavior when that file exists; never applied to unrelated commands. An explicit `-c` skips discovery entirely. | -| `scan [repository] --no-config` | Skip project YAML only. Keep legacy deep defaults, credentials, managed policy, and environment-based runtime/state settings. | Reject combination with `--config`. This is not a clean-room run or a bypass for existing user configuration. | -| `init [directory]` | Write a small commented YAML file; default to the invocation directory and leave existing files unchanged. | No login, dependency installation, inference, or scan. | -| `config validate [-c FILE]` | Validate the file's wrapper schema and combinations without loading runtime providers. Exit `0` when valid and `2` when invalid or missing. | Separate from the existing finding-validation command. Does not certify runtime availability. | -| `config show [-c FILE \| --no-config] [--json]` | Show file/default settings and their sources, even when no project file exists. | No inference or raw credential-bearing config dump. The existing scan dry run remains the invocation-specific preview. | - -Keep help, version, and schema output independent of file validity. These commands -should use the same parser/resolver, not instantiate a scan to inspect settings. -Discovery also needs an explicit review of native tool/profile settings in -repository-owned YAML; its introduction should not be hidden in the initial loader -change. No new bypass for the legacy deep TOML is proposed here. - -Once configuration resolution is shared, a later increment can let -`scan -c FILE` describe a monorepo component plan or an explicitly selected repository -portfolio. Use a single `repository` with explicit `components`, or a mutually -exclusive `repositories` list. Preserve full revision pinning for portfolio inputs, -component report aggregation, retry receipts, and separate repository histories. -Keep the current CSV and JSON inputs as adapters to the same internal plan. - -That increment should introduce an outer `execution.concurrency` and -`execution.maxAttempts`, separate from `scan.deep.workers` and -`scan.deep.subagentsPerWorker`. It must preserve the current standard-only -component behavior until another change adds deep component support. Automatic -component planning remains explicit model work; `init`, config inspection, and -local dry-run checks should not silently perform it. - -The following CLI changes are candidates for that later compatibility discussion, -not prerequisites for the first YAML increment: - -| Current surface | Possible preferred surface | Compatibility requirement | -| --------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -| `bulk-scan` and `scan-components` | `scan -c FILE` with explicit orchestration input | Retain CSV/JSON and old-command adapters; do not infer components from ordinary paths. | -| `--workers` on bulk/components | `--concurrency` | Preserve the old outer-concurrency meaning as an alias. | -| `--workers` on deep scans | `--deep-workers` | Preserve the old discovery-worker meaning as an alias. | -| `--subagents` | `--subagents-per-worker` | Preserve zero and per-worker semantics. | -| `--max-cost` | `--max-scan-cost` | Never silently reinterpret an existing per-attempt limit as a total-run limit. | -| `--diff BASE` | Also accept `--base BASE [--head HEAD]` for committed changes | Retain `--working-tree --base REF`, alias checks, and existing Git comparison semantics. | -| `login`, `login status`, `logout` | `auth login`, `auth status`, `auth logout` | Keep existing entrypoints as compatibility aliases. | -| `install-hook` | `hooks install` | Preserve hook installation behavior. | -| `dedupe` | `findings dedupe` | Make its model and findings-API work explicit. | - -Keep `scan [repository]` short and retain useful direct finding-action commands. -Keep `--to` as the only generic integration selector, with destination-specific -fields such as `--linear-team` and `--linear-project`. Do not generalize integration -fields whose meanings differ. - -Output cleanup should proceed separately from command renaming. Help should list -only formats and filters that a command supports, including accepted JSON aliases. -Keep result serialization distinct from `--export-format`, `--output`, and -`--output-dir`. Do not repurpose the existing `--format` flag or silently change -structured output as part of adding YAML. Matching, comparison, and deduplication -should state when they invoke models or persist results. - -Preserve scan exit behavior during the rollout: `0` for completed work without a -policy breach, `1` for a finding-policy breach, `2` for invalid, failed, or incomplete -work, and existing cancellation codes. YAML must not make a partial scan pass. -`policy.failOnSeverity` changes the exit decision, not which findings are retained. -When batch/components adopt the policy, operational failures take precedence over -a clean severity result. The sample threshold of `high` does not change the -report-only default. - -The implementation should be delivered in focused increments: - -1. Add explicit `scan -c FILE` for equivalent YAML/JSON input, its generated and - packaged editor schema, shared resolution, and integration with existing dry - run and scan execution. Resolve effective deep - settings once and persist those values in recipes. Preserve scan defaults without - `-c` and document earlier deep validation; do not block on CLI renaming or a - general metadata refactor. -2. Add agreed discovery, project-only bypass, initialization, and config inspection - using that same resolver. Test malformed discovered files and explicit-file - selection through the real CLI. -3. Adapt batch/component inputs and CI policy to shared settings while preserving - orchestration, inventories, retry behavior, and capability limits. The proposed - portfolio/component schema needs its own review before accepting new shapes. - -Review alias/profile precedence repairs, output/help cleanup, command renaming, -complete input replay, and total-run cost accounting independently. They address -real concerns but are not all prerequisites for loading one project file. Any -accepted behavior change needs its own compatibility notes and validation. - -Acceptance checks should cover observable behavior: - -- Equivalent supported CLI and YAML input resolves to the same effective options. - Exercise the actual argument parser and action schema, not just a hand-built - option object passed to a resolver. -- Equivalent YAML and JSON input passes the same input contract. Editor metadata - does not select another runtime validator, and the packaged schema works offline. - The file loader and generated schema agree on structural input fixtures without - modifying them, including reserved unknown wrapper keys. -- Absent arguments remain absent through both parser layers. Explicit false/zero - and supported list values survive; scope selection replaces the file variant, - and dependent scope flags are checked after merging. -- Explicit selection reads only the selected file. Legacy deep settings and native - profiles follow the documented rules, including from another working directory. -- Dry run, execution, and new recipes consume the same resolved deep defaults, - including the error-stop setting previously missing from the TypeScript adapter. -- Repository-relative and config-relative paths remain distinct and work on - Windows; current output and protected-path rules remain enforced. -- Invalid wrapper keys, types, and active combinations fail before - inference. Help/schema output and local dry runs do not launch models, publish - results, or initialize live scan history just to inspect settings. -- New recipes do not absorb edited YAML or changed deep defaults. Reruns identify - their current-file/source limitations and retain explicit prompt requirements. - Exact input replay remains outside the initial acceptance claim. -- Existing credential exclusions apply to configuration inspection and recipes. - Existing machine output and scan exit codes remain compatible. - -The first implementation review should settle the field names, native pass-through -boundary, and effective deep-settings adapter. The later discovery increment adds -checks for project-only bypass, invalid ambient files, and inspection without -runtime initialization. Broader command restructuring and portfolio shape should -remain separate from proving the single-scan configuration contract. diff --git a/docs/proposals/configuration-schema.md b/docs/proposals/configuration-schema.md deleted file mode 100644 index bdd244b30..000000000 --- a/docs/proposals/configuration-schema.md +++ /dev/null @@ -1,197 +0,0 @@ -# Promptfoo alignment and JSON Schema design - -**Status: design implemented in the local CLI prototype.** This supplements the -[project configuration proposal](cli-and-project-configuration.md) and its -[implementation review](cli-and-project-configuration-review.md). The -[generated JSON Schema](../../sdk/typescript/schemas/project-config.schema.json) and equivalent -[YAML](examples/codex-security.yaml) / [JSON](examples/codex-security.json) examples -use the prototype input contract. See the [prototype guide](../project-configuration.md) -for commands and limits; this feature has not been released. - -The proposal aligns closely with Promptfoo's workflow and schema-generation -pattern. Its configuration vocabulary is specific to repository security scans; -the files are not interchangeable with Promptfoo configurations. The first -increment also delivers less convenience than Promptfoo because discovery and -initialization are deferred. Editor schema support should ship with the first -usable file loader, even if those commands follow later. - -| Area | Promptfoo | Proposed Codex Security behavior | Alignment | -| ---------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -| Normal invocation | `eval -c FILE` with file defaults and CLI overrides | `scan -c FILE` with file defaults and explicit CLI overrides | Close workflow match; native profile semantics remain separately documented. | -| File formats | YAML, JSON, and executable JS/TS variants | YAML and JSON as two encodings of one input object | Match the common data formats; omit executable configuration. | -| Discovery and initialization | Discoverable `promptfooconfig` and `init` | Explicit selection first; discovery and `init` in a follow-up | A staged product difference, not a different long-term goal. | -| Editor support | Zod-derived JSON Schema, schema comments, generated assets checked in CI | Generated JSON Schema and matching examples in the first increment | Adopt directly, with input/runtime agreement checks. | -| Configuration vocabulary | Prompts, providers/targets, tests, red-team settings | Scan scope, deep discovery, native Codex settings, scan limits, finding exit policy | Different domains; do not copy field names without matching meaning. | -| Runtime controls | Both `commandLineOptions` and `evaluateOptions` | One canonical location per setting | Intentional simplification. | -| Composition | Multiple files, globs, field-specific merges, references, environment templates | One explicit file; ordinary literal values and documented path rules | Smaller initial contract. | -| Validation behavior | Some coercion, unknown-key stripping, warning paths, and later resource loading | Strict wrapper input types/keys, then active-scan checks after merging | Deliberate behavior difference. | -| Native configuration | Promptfoo provider abstractions | Existing native Codex vocabulary under `codex` | Preserve the thin-wrapper architecture. | -| Versioning | The inspected project schema has no required format-version field | No project format-version field; schema ships with the package | Same minimal configuration shape. | - -This comparison is based on Promptfoo 0.121.19 at -[ce4c59d](https://github.com/promptfoo/promptfoo/tree/ce4c59d93f055c9dfbbb66d841f681909089ddf0), -including its [file reader](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/util/config/load.ts#L328-L414), -[schema generator](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/scripts/generateJsonSchema.ts#L88-L155), -and [generated-asset CI check](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/.github/workflows/main.yml#L321-L335). - -There are several different schema contracts here. They should not become one -large schema merely because they all use JSON Schema: - -| Contract | Owner and purpose | Proposed treatment | -| ----------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | -| Project input | Wrapper-owned settings in YAML/JSON | Add one canonical serializable input definition and generate its editor schema. | -| Native `codex` configuration | Codex's own settings and profiles | Retain native validation; do not hand-maintain a second exhaustive native schema. | -| CLI introspection | Incur's `scan --schema --json` describes arguments and options | Preserve it. It is not the project-file schema and includes action-specific flags excluded from project defaults. | -| Scan artifacts | Findings, coverage, manifests, and other existing plugin contracts | Leave their schemas and versioning unchanged. They describe results, not user configuration. | -| Resolved settings and recipes | Internal effective inputs and saved rerun metadata | Keep separate types and compatibility checks. Do not invent another public file format until a consumer needs one. | - -The existing [CLI schema tests](../../sdk/typescript/tests-ts/cli.test.ts) and -[artifact validator](../../sdk/typescript/src/contract.ts) already establish those -separate responsibilities. Artifact schemas use Draft 2020-12 and the SDK already -depends on Ajv 8.20.0. No new JSON Schema engine is needed. - -For project input, use a small Zod definition containing serializable values only, -and generate JSON Schema from that definition. This follows Promptfoo's approach -and fits the existing Zod-based CLI. Infer the TypeScript input type from the same -definition. Keep callbacks, abort signals, runtime objects, path resolution, and -default application outside it. The prototype uses the generated schema with the -SDK's existing Ajv engine for file validation. Direct probes found that Zod's -cloned output silently omits reserved object keys, even in strict wrapper objects; -Ajv rejects unknown wrapper keys and retains native input for the existing -override checks. Existing artifact schemas remain authoritative for their own -contracts; this is not a migration of all schemas to Zod. - -The proposed generation settings are: - -```ts -z.toJSONSchema(ProjectConfigInputSchema, { - target: "draft-07", - io: "input", - unrepresentable: "throw", -}); -``` - -Draft-07 matches Promptfoo's project schema and Codex's published native schema. -It supports the needed objects, enums, unions, and numeric constraints. There is -no need to change existing Draft 2020-12 artifact schemas. Current -[YAML Language Server documentation](https://github.com/redhat-developer/yaml-language-server#yaml-language-server) -supports both dialects; this choice does not claim that editors require Draft-07. - -Zod's default conversion describes parsed output, while `io: "input"` describes -what authors supply. Use strict objects for wrapper-owned sections so the generated -schema rejects unknown keys. Keep unsupported runtime values out of the schema rather than -converting them to an unconstrained placeholder. These options and their limits -are documented in [Zod's JSON Schema guide](https://zod.dev/json-schema). - -The generator is only part of the contract. Direct probes against Promptfoo's -runtime `UnifiedConfigSchema` and checked-in JSON Schema produced these results: - -| Input | Runtime Zod schema | Generated JSON Schema | -| ---------------------------------------- | ----------------------------- | ------------------------------- | -| Ordinary prompt plus `providers: [echo]` | Accept | Accept | -| Neither `providers` nor `targets` | Reject | Accept | -| Both `providers` and `targets` | Reject | Accept | -| Unknown root key | Accept and strip | Reject | -| Root `$schema` metadata | Accept and strip | Reject in direct Ajv validation | -| `commandLineOptions.maxConcurrency: "2"` | Accept and coerce to a number | Reject | -| Object-valued transform | Reject | Reject | - -These were direct schema comparisons, not fresh evaluations or editor tests. The -runtime's [provider/target refinement](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/src/types/index.ts#L1320-L1348) -is absent from the inspected generated root schema. Its output-oriented numeric -schema also does not describe every coerced input. Promptfoo has useful -[Ajv regression tests](https://github.com/promptfoo/promptfoo/blob/ce4c59d93f055c9dfbbb66d841f681909089ddf0/test/config-schema.test.ts#L30-L60), -but generation and a valid metaschema do not by themselves establish agreement. - -For the proposed wrapper schema, use normal structural unions where possible. -The input scope is an `anyOf` of three strict objects: one requires `paths`, one -`diff`, and one `workingTree`. Because each branch rejects the other branches' -keys, this permits exactly one variant. Avoid encoding the rule only in a custom -refinement that an editor schema might lose. - -**Defaults belong to resolution.** A JSON Schema `default` is an annotation, not -an instruction to populate a missing property during validation. The schema includes -built-in defaults as documentation hints; a legacy user setting may still supply a -different effective value. Keep Ajv's `useDefaults`, `coerceTypes`, and -`removeAdditional` disabled. Keep Zod input fields optional without `.default()` -or `.prefault()`. Apply defaults once, after retaining which values each layer -actually supplied. See [JSON Schema annotations](https://json-schema.org/understanding-json-schema/reference/annotations) -and [Ajv's data-modification options](https://ajv.js.org/options.html#options-to-modify-validated-data). - -**Input validity and executable scan validity are separate.** The input schema -checks wrapper keys, field types, the scope union, and current -numeric bounds. It deliberately does not reject every combination of individually -valid settings before CLI overrides. For example, a file containing deep mode and -a diff scope is structurally valid input, but cannot execute unless an override -changes the incompatible mode or scope. The existing scan checks must reject an -incompatible resolved combination before inference. The same applies to a custom -validation file with active deep mode. A future `config validate` command should -validate the resolved file/default invocation as well as its structure. - -Filesystem existence, Git refs, protected output paths, authentication, model -availability, and cost estimation are not certified by JSON Schema. Nor does the -schema resolve file-relative paths. These remain existing local/native checks; -do not add custom schema keywords that secretly perform I/O. - -**The native block needs a narrower promise.** Official OpenAI documentation links -a [native Codex configuration schema](https://learn.chatgpt.com/docs/config-file/config-reference#configtoml). -The currently published schema accepted this checkout's default native settings in -a local Ajv probe. However, it is not identical to the wrapper's contract: it -rejects unknown native keys that the wrapper currently passes through, while it -permits plugin-loading configuration that the wrapper owns and rejects. - -The prototype therefore checks common model/provider key types and permits other -JSON-valued native keys, with existing native and wrapper checks still required. -This means typo detection and completion are intentionally incomplete inside -`codex`. Do not claim that the project schema validates every native option. - -If full native editor completion is added, reuse an upstream schema matched to the -packaged Codex version. Bundle its references and apply existing wrapper rules -without forking its vocabulary by hand. Do not use the changing latest-schema URL -as a mandatory runtime validation gate. Matching one current default object is -not enough to establish complete version compatibility. - -**Editor metadata does not select runtime validation.** - -| Location | Field | Meaning | -| --------------- | --------- | --------------------------------------------------------------------------------------------------------------------- | -| Schema document | `$schema` | JSON Schema dialect, such as Draft-07. | -| Schema document | `$id` | Optional identity of that schema document. The prototype omits it; editors resolve references from the file location. | -| Project file | `$schema` | Optional editor hint pointing to the project schema; not a request for the CLI to fetch or trust another validator. | - -The YAML example uses Promptfoo's familiar language-server comment, with a -relative path to the generated package schema. The JSON example uses a root `$schema` -property explicitly allowed by the input definition. After removing that metadata, -both examples describe exactly the same settings. Runtime validation uses the -schema bundled with the installed package. There is no project format-version field. - -The prototype includes the generated schema in the npm package at -`@openai/codex-security/schemas/project-config.schema.json`. The -[package manifest](../../sdk/typescript/package.json) exports that path, and the -package check requires the file. Editors and CI can use the matching package -copy offline. A hosted schema URL can follow once it is actually available. -Keep an exact package-version copy for clients that need to match an older CLI; -a floating latest schema can describe fields that an installed CLI does not accept. -Do not add or repurpose a CLI flag merely to print a schema already shipped as a -file. In particular, preserve the existing command-introspection `--schema` output. - -The first implementation's verification should cover: - -- Metaschema validity and deterministic generation, with a CI check for changes in - the generated artifact, following Promptfoo's generated-asset check. -- The same positive and negative input fixtures through Zod and Ajv, including - unknown keys, literal numeric types, zero subagents, scope exclusivity, metadata, - empty configurations, and absence of inserted defaults. -- YAML and JSON examples resolving to the same settings after editor metadata is - removed, plus real CLI tests for file/flag precedence and active-scan validation. -- Schema references resolving from the packed package without a network request, - and at least one actual YAML/JSON language-service completion/diagnostic check. -- Existing result schemas, command introspection, credential exclusions, and - native runtime checks remaining unchanged unless separately reviewed. - -For this proposal, positive/negative draft fixtures agreed between Zod 4.4.3 -and Ajv 8.20.0, and accepted inputs were neither default-filled nor stripped. -Eight Promptfoo comparisons and three native-schema probes informed the boundaries -above. Those initial comparisons predated implementation. The prototype now -keeps 26 cases in [regression tests](../../sdk/typescript/tests-ts/project-config.test.ts), -with separate [CLI tests](../../sdk/typescript/tests-ts/cli-project-config.test.ts). diff --git a/docs/proposals/examples/codex-security.json b/docs/proposals/examples/codex-security.json deleted file mode 100644 index 93232568f..000000000 --- a/docs/proposals/examples/codex-security.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "../../../sdk/typescript/schemas/project-config.schema.json", - "scan": { - "mode": "standard", - "scope": { "paths": ["src"] } - }, - "codex": { - "model": "gpt-5.6-sol", - "model_reasoning_effort": "xhigh" - }, - "limits": { "maxCostUsdPerScan": 10 }, - "policy": { "failOnSeverity": "high" } -} diff --git a/docs/proposals/examples/codex-security.yaml b/docs/proposals/examples/codex-security.yaml deleted file mode 100644 index 9e6398868..000000000 --- a/docs/proposals/examples/codex-security.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# yaml-language-server: $schema=../../../sdk/typescript/schemas/project-config.schema.json -# Local prototype format; not yet released. - -scan: - mode: standard - scope: - paths: [src] - -codex: - model: gpt-5.6-sol - model_reasoning_effort: xhigh - -limits: - maxCostUsdPerScan: 10 - -policy: - failOnSeverity: high diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 211c9cd87..87d8d9fc6 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -269,10 +269,9 @@ Use `scan --help` for options, `--version` for the installed version, and `info --json` for package, plugin, runtime, and model details. `--dry-run` runs local preflight checks. -### Project files (local prototype) +### Project files -This working tree supports `scan -c FILE` / `scan --config FILE`. The feature is -not yet released. In a locally built or installed package: +Use `scan -c FILE` / `scan --config FILE` to load reusable scan settings: ```bash codex-security scan . -c codex-security.yaml --dry-run --json @@ -297,15 +296,11 @@ policy: failOnSeverity: high ``` -All settings are optional; `{}` uses the existing defaults. The schema is included at -`@openai/codex-security/schemas/project-config.schema.json` and generated from -one Zod input definition. The loader validates it with the existing Ajv engine, -without coercion, default insertion, or key stripping. JSON files may use a root -`$schema` string with the same relative path. The hint is editor metadata; the CLI -uses its bundled schema and does not fetch schema URLs. Wrapper keys -and types are strict. Native keys under `codex` retain existing validation and -profile semantics; editor completion there covers common model/provider fields. -CLI `scan --schema --json` still describes command arguments, not project files. +All settings are optional; `{}` uses the existing defaults. JSON files can use a +root `$schema` string pointing to the same packaged schema. Schema hints are for +editors; the CLI uses its bundled validator without fetching URLs, coercing values, +or dropping unknown keys. Native `codex` settings retain their existing checks and +profile semantics. CLI `scan --schema --json` describes command arguments. Settings use built-in defaults, applicable legacy deep defaults, the file, then explicit CLI values. Lists and scope variants are replaced. `--head` can refine diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 07328bc72..b3ee95a10 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -3069,7 +3069,7 @@ function scanRecipe( failOnSeverity?: SeverityLevel, knowledgeBasePaths?: string[], maxCostUsd?: number, - deepScan?: DeepScanOptions, + deepScan?: Required, auth?: ScanAuthMode, ): JsonObject { return { @@ -3090,7 +3090,7 @@ function scanRecipe( ...(failOnSeverity === undefined ? {} : { failOnSeverity }), ...(knowledgeBasePaths === undefined ? {} : { knowledgeBasePaths }), ...(maxCostUsd === undefined ? {} : { maxCostUsd }), - ...(deepScan === undefined || Object.keys(deepScan).length === 0 + ...(deepScan === undefined ? {} : { deepScan: { ...deepScan }, deepScanResolved: true }), }; diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 2004907b6..c892ba067 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -956,13 +956,9 @@ interface ScanArguments extends ScanSettings { verbose?: boolean; repository?: string; postScanPromptFile?: string; - model?: string; - effort?: ScanReasoningEffort; - provider?: "openai" | "amazon-bedrock" | ExternalModelProvider; archiveExisting: boolean; pluginPath?: string; pythonPath?: string; - codex: string[]; patch?: boolean; patchSeverity?: FailureSeverity; createPr?: boolean; @@ -3002,13 +2998,9 @@ export async function main( verbose: options.verbose, repository: args.repository, postScanPromptFile: options.postScanPromptFile, - model: options.model, - effort: options.effort, - provider: options.provider, archiveExisting: options.archiveExisting, pluginPath: options.pluginPath, pythonPath: options.python, - codex: options.codex, patch: options.patch, patchSeverity: options.patchSeverity, createPr: options.createPr, @@ -4641,7 +4633,6 @@ function scanArgumentsFromRecipe( mode, ...deepScan.data, archiveExisting: false, - codex: [], codexOverrides: Object.hasOwn(config, "approval_policy") ? config : { ...config, approval_policy: "never" }, @@ -6414,14 +6405,7 @@ async function executeScan( const config: CodexSecurityConfig = { pluginPath: arguments_.pluginPath, pythonPath: arguments_.pythonPath, - codexOverrides: - arguments_.codexOverrides ?? - parseCodexOverrides( - arguments_.codex, - arguments_.model, - arguments_.effort, - arguments_.provider, - ), + codexOverrides: arguments_.codexOverrides, }; const selectedProfileName = config.codexOverrides?.["profile"]; const effectiveConfiguration = { diff --git a/sdk/typescript/src/project-config.ts b/sdk/typescript/src/project-config.ts index b9535eb96..035e47de2 100644 --- a/sdk/typescript/src/project-config.ts +++ b/sdk/typescript/src/project-config.ts @@ -45,7 +45,7 @@ export interface ScanSettings extends DeepScanOptions { outputDir?: string; failOnSeverity?: Exclude; maxCostUsd?: number; - codexOverrides?: JsonObject; + codexOverrides: JsonObject; } export type ConfigurationSource = "default" | "legacy" | "project" | "cli"; diff --git a/sdk/typescript/tests-ts/cli-project-config.test.ts b/sdk/typescript/tests-ts/cli-project-config.test.ts index e3fa70787..3398f9e99 100644 --- a/sdk/typescript/tests-ts/cli-project-config.test.ts +++ b/sdk/typescript/tests-ts/cli-project-config.test.ts @@ -193,7 +193,7 @@ test.each([ ], [{ workingTree: {} }, ["--no-working-tree"], "repository"], ] as const)( - "resolves scope selectors and dependent refs after merging (%j)", + "resolves scope %j with overrides %j", async (scope, flags, target) => { const input = await fixture({ scan: { scope: structuredClone(scope) }, From 0ea24644ce1d3d9dbdc5fa6088eb72654bd956a1 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 29 Aug 2026 19:35:17 -0700 Subject: [PATCH 03/12] fix(cli): repair configuration regressions and Windows tests --- sdk/typescript/src/cli.ts | 5 +- sdk/typescript/src/config.ts | 13 ++- sdk/typescript/src/deep-config.ts | 46 +++++---- sdk/typescript/tests-ts/api.test.ts | 98 ++++++++++++------- .../tests-ts/cli-project-config.test.ts | 70 +++++++++++++ sdk/typescript/tests-ts/cli.test.ts | 9 +- sdk/typescript/tests-ts/deep-config.test.ts | 55 ++++++++++- 7 files changed, 233 insertions(+), 63 deletions(-) diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index c892ba067..81f1abb5f 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -84,7 +84,9 @@ import { DEFAULT_CODEX_CONFIG, EXTERNAL_CODEX_PROVIDERS, isExternalModelProvider, + mergeCodexOverrides, mergedCodexConfig, + scanModel, scanModelConfiguration, scanModelProvider, type CodexSecurityConfig, @@ -7536,8 +7538,7 @@ export function parseCodexOverrides( } if ( (isExternalModelProvider(provider) || provider === "amazon-bedrock") && - !("model" in result) && - defaults?.["model"] === undefined + scanModel(mergeCodexOverrides(defaults ?? {}, result)) === undefined ) { throw new CodexSecurityError( `--model is required when using --provider ${provider}`, diff --git a/sdk/typescript/src/config.ts b/sdk/typescript/src/config.ts index 5948d0a77..2852ae6a5 100644 --- a/sdk/typescript/src/config.ts +++ b/sdk/typescript/src/config.ts @@ -79,10 +79,7 @@ export function scanModelConfiguration( config: Readonly, ): ScanModelConfiguration { const selectedProfile = selectedScanProfile(config); - const model = - selectedProfile !== undefined && Object.hasOwn(selectedProfile, "model") - ? selectedProfile["model"] - : config["model"]; + const model = scanModel(config); if (typeof model !== "string" || model.trim().length === 0) { throw new ConfigurationError( "The configured Codex model must be a nonempty string.", @@ -104,6 +101,14 @@ export function scanModelConfiguration( return { model, reasoningEffort }; } +export function scanModel(config: Readonly): unknown { + const selectedProfile = selectedScanProfile(config); + return selectedProfile !== undefined && + Object.hasOwn(selectedProfile, "model") + ? selectedProfile["model"] + : config["model"]; +} + export function scanModelProvider(config: Readonly): unknown { const selectedProfile = selectedScanProfile(config); return selectedProfile !== undefined && diff --git a/sdk/typescript/src/deep-config.ts b/sdk/typescript/src/deep-config.ts index af8876566..ff096f0f1 100644 --- a/sdk/typescript/src/deep-config.ts +++ b/sdk/typescript/src/deep-config.ts @@ -53,18 +53,7 @@ export async function resolveDeepScanConfig( let document: TomlTable = {}; // Complete saved settings do not depend on today's ambient configuration. if (!DEEP_SCAN_SETTINGS.every(([name]) => explicit[name] !== undefined)) { - try { - document = parseToml( - await readFile(source, { encoding: "utf8", signal }), - ); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw new CodexSecurityError( - `Cannot read Codex Security configuration at ${source}.`, - { cause: error }, - ); - } - } + document = await readDeepScanDocument(source, signal); } const existing = document["deep_scan"]; if ( @@ -117,17 +106,36 @@ export async function writeDeepScanConfig( destination: string, resolved: ResolvedDeepScanConfig, ): Promise { - if (!resolved.hasOverrides) { - const [source, target] = await Promise.all([ - realpath(resolved.source).catch(() => null), - realpath(destination).catch(() => null), - ]); - if (source !== null && source === target) return; + const [source, target] = await Promise.all([ + realpath(resolved.source).catch(() => null), + realpath(destination).catch(() => null), + ]); + let document = resolved.document; + if (source !== null && source === target) { + if (!resolved.hasOverrides) return; + document = await readDeepScanDocument(destination); } await writeCodexConfig(destination, { - ...resolved.document, + ...document, deep_scan: Object.fromEntries( DEEP_SCAN_SETTINGS.map(([name, key]) => [key, resolved.settings[name]]), ), } as JsonObject); } + +async function readDeepScanDocument( + source: string, + signal?: AbortSignal, +): Promise { + try { + return parseToml(await readFile(source, { encoding: "utf8", signal })); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw new CodexSecurityError( + `Cannot read Codex Security configuration at ${source}.`, + { cause: error }, + ); + } + return {}; + } +} diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 24a85f0b0..9a1ce5fe9 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -2884,43 +2884,73 @@ describe("CodexSecurity orchestration", () => { }, ); - test("preserves ambient configuration when the deep-scan runtime uses the same home", async () => { - const root = await temporaryDirectory(); - const repository = join(root, "repository"); - const codexHome = join(root, "codex-home"); - const scanDir = join(root, "scan"); - const configPath = join(codexHome, "codex-security", "config.toml"); - const originalConfiguration = "[other]\nenabled = true\n"; - await mkdir(repository); - await mkdir(join(codexHome, "codex-security"), { recursive: true }); - await writeFile(configPath, originalConfiguration); - await mkdir(scanDir, { mode: 0o700 }); + test.each(["defaults", "complete overrides"])( + "preserves ambient configuration when the deep-scan runtime uses the same home with %s", + async (settings) => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const codexHome = join(root, "codex-home"); + const scanDir = join(root, "scan"); + const configPath = join(codexHome, "codex-security", "config.toml"); + const originalConfiguration = "[other]\nenabled = true\n"; + await mkdir(repository); + await mkdir(join(codexHome, "codex-security"), { recursive: true }); + await writeFile(configPath, originalConfiguration); + await mkdir(scanDir, { mode: 0o700 }); - const client = new TestClient( - {}, - { - environment: { CODEX_HOME: codexHome }, - prepareRuntime: async () => preparedRuntime(codexHome), - resolvePluginPython: async () => "/managed/python", - prepareOutputDir: async () => scanDir, - repositoryRevision: async () => "deadbeef", - createCodex: () => ({ - startThread: () => ({ - id: null, - async runStreamed() { - throw new Error("deep scan settings captured"); - }, + const client = new TestClient( + {}, + { + environment: { CODEX_HOME: codexHome }, + prepareRuntime: async () => preparedRuntime(codexHome), + resolvePluginPython: async () => "/managed/python", + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => "deadbeef", + createCodex: () => ({ + startThread: () => ({ + id: null, + async runStreamed() { + throw new Error("deep scan settings captured"); + }, + }), }), - }), - }, - ); + }, + ); - await expect(client.run(repository, { mode: "deep" })).rejects.toThrow( - "deep scan settings captured", - ); - expect(await readFile(configPath, "utf8")).toBe(originalConfiguration); - await client.close(); - }); + await expect( + client.run(repository, { + mode: "deep", + ...(settings === "complete overrides" + ? { + workers: 2, + subagents: 0, + stopAfterNoNew: 7, + stopAfterConsecutiveErrors: 2, + maxDiscoveryRuns: 12, + maxTimeHours: 1.5, + } + : {}), + }), + ).rejects.toThrow("deep scan settings captured"); + const written = await readFile(configPath, "utf8"); + if (settings === "defaults") { + expect(written).toBe(originalConfiguration); + } else { + expect(parseToml(written)).toEqual({ + other: { enabled: true }, + deep_scan: { + workers: 2, + subagents: 0, + stop_after_no_new: 7, + stop_after_consecutive_errors: 2, + max_discovery_runs: 12, + max_time_hours: 1.5, + }, + }); + } + await client.close(); + }, + ); test.skipIf(process.platform !== "win32")( "preserves ambient configuration when the same Windows home uses different casing", diff --git a/sdk/typescript/tests-ts/cli-project-config.test.ts b/sdk/typescript/tests-ts/cli-project-config.test.ts index 3398f9e99..cae7a929c 100644 --- a/sdk/typescript/tests-ts/cli-project-config.test.ts +++ b/sdk/typescript/tests-ts/cli-project-config.test.ts @@ -342,6 +342,76 @@ test("an explicit file can supply the model required by a provider override", as }); }); +test.each(["file", "CLI"])( + "a profile selected by the %s supplies the provider model", + async (selection) => { + const overrideProfile = selection === "CLI"; + const input = await fixture({ + codex: { + profile: overrideProfile ? "other" : "review", + profiles: { + review: { model: "synthetic-profile-model" }, + other: { model: "synthetic-other-model" }, + }, + }, + }); + let native: CodexSecurityConfig | undefined; + expect( + await main( + [ + "scan", + "-c", + input.config, + "--provider", + "amazon-bedrock", + ...(overrideProfile ? ["--codex", 'profile="review"'] : []), + "--json", + ], + capture().stream, + capture().stream, + dependencies({ + currentDirectory: input.repository, + onConfig: (value) => { + native = value; + }, + }), + ), + ).toBe(0); + expect(native?.codexOverrides).toMatchObject({ + profile: "review", + profiles: { review: { model: "synthetic-profile-model" } }, + model_provider: "amazon-bedrock", + }); + }, +); + +test("an unselected file profile does not satisfy the provider model requirement", async () => { + const input = await fixture({ + codex: { + profiles: { review: { model: "synthetic-profile-model" } }, + }, + }); + const stderr = capture(); + let initialized = false; + expect( + await main( + ["scan", "-c", input.config, "--provider", "amazon-bedrock", "--json"], + capture().stream, + stderr.stream, + dependencies({ + currentDirectory: input.repository, + onConfig: () => { + initialized = true; + }, + }), + ), + ).toBe(2); + expect(initialized).toBe(false); + expect(stderr.text()).toContain( + "--model is required when using --provider amazon-bedrock", + ); +}); + test.each([ { argv: ["scan", "--help"] }, { argv: ["scan", "--schema", "--json"] }, diff --git a/sdk/typescript/tests-ts/cli.test.ts b/sdk/typescript/tests-ts/cli.test.ts index dcb5d2545..ef277858c 100644 --- a/sdk/typescript/tests-ts/cli.test.ts +++ b/sdk/typescript/tests-ts/cli.test.ts @@ -8,7 +8,7 @@ import { writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { delimiter, join, normalize } from "node:path"; +import { delimiter, join, normalize, resolve } from "node:path"; import { Writable } from "node:stream"; import { fileURLToPath } from "node:url"; import { stripVTControlCharacters } from "node:util"; @@ -2587,7 +2587,10 @@ describe("CLI", () => { ).toBe(0); expect(pathOptions).toMatchObject({ target: ["src", "--fixtures"], - knowledgeBasePaths: ["/shared/architecture.pdf", "/shared/threat-models"], + knowledgeBasePaths: [ + resolve("/shared/architecture.pdf"), + resolve("/shared/threat-models"), + ], workers: 2, subagents: 0, stopAfterNoNew: 3, @@ -5065,7 +5068,7 @@ describe("CLI", () => { dependencies({ onTurn: (_repository, options) => { expect(options).toMatchObject({ - outputDir: "/tmp/results", + outputDir: resolve("/tmp/results"), archiveExisting: true, }); ( diff --git a/sdk/typescript/tests-ts/deep-config.test.ts b/sdk/typescript/tests-ts/deep-config.test.ts index 849dc8e71..ebb4dd87f 100644 --- a/sdk/typescript/tests-ts/deep-config.test.ts +++ b/sdk/typescript/tests-ts/deep-config.test.ts @@ -4,10 +4,11 @@ import { readFile, realpath, rm, + symlink, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { afterEach, expect, test } from "bun:test"; import { parse as parseToml } from "smol-toml"; import { @@ -109,6 +110,58 @@ test("complete saved settings do not read a changed or invalid legacy file", asy const result = await resolveDeepScanConfig(saved, input.source); expect(result.settings).toEqual(saved); expect(new Set(Object.values(result.sources))).toEqual(new Set(["override"])); + const destination = join(input.root, "runtime", "deep-scan.toml"); + await writeDeepScanConfig(destination, result); + expect(parseToml(await readFile(destination, "utf8"))).toMatchObject({ + deep_scan: { workers: 2, subagents: 0, stop_after_no_new: 7 }, + }); + expect(await readFile(input.source, "utf8")).toBe("not valid TOML ["); +}); + +test.each(["same path", "directory link"])( + "complete settings preserve ambient sections through the %s", + async (destinationKind) => { + const input = await fixture("[other]\nenabled = true\n"); + const result = await resolveDeepScanConfig( + { ...DEFAULT_DEEP_SCAN_SETTINGS, workers: 2 }, + input.source, + ); + await writeFile( + input.source, + "[deep_scan]\nworkers = 9\n[other]\nenabled = false\n", + ); + let destination = input.source; + if (destinationKind === "directory link") { + const link = join(input.root, "runtime"); + await symlink(dirname(input.source), link, "junction"); + destination = join(link, "config.toml"); + } + await writeDeepScanConfig(destination, result); + expect(parseToml(await readFile(input.source, "utf8"))).toEqual({ + deep_scan: { + workers: 2, + subagents: 3, + stop_after_no_new: 4, + stop_after_consecutive_errors: 3, + max_discovery_runs: 40, + max_time_hours: 96, + }, + other: { enabled: false }, + }); + }, +); + +test("complete settings do not overwrite an invalid ambient destination", async () => { + const contents = "not valid TOML ["; + const input = await fixture(contents); + const result = await resolveDeepScanConfig( + DEFAULT_DEEP_SCAN_SETTINGS, + input.source, + ); + await expect(writeDeepScanConfig(input.source, result)).rejects.toThrow( + "Cannot read Codex Security configuration", + ); + expect(await readFile(input.source, "utf8")).toBe(contents); }); test("runtime preparation writes the snapshot even if the ambient file changes", async () => { From 1e33329e85ae3d58c0d30beb8bcbbf23cadd29b5 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 29 Aug 2026 20:38:03 -0700 Subject: [PATCH 04/12] feat(sdk): share project configuration with the CLI Resolve project files and typed configuration into shared SDK scan settings. Keep native Codex configuration separate from scan options and expose public loadProjectConfig and resolveProjectConfig entry points. Reuse prompt-file handling and severity comparison across SDK and CLI paths. Preserve existing flags, file fields, defaults, output, and input protections. Cover equivalent inputs, prompt-backed workflows, and installed package APIs. --- docs/project-configuration.md | 81 +++++ sdk/typescript/README.md | 72 +++- sdk/typescript/scripts/check-package.mjs | 1 + .../scripts/fixtures/package-consumer.ts | 23 ++ sdk/typescript/scripts/smoke-package.mjs | 12 +- sdk/typescript/src/api.ts | 44 ++- sdk/typescript/src/cli.ts | 343 ++++++++---------- sdk/typescript/src/deep-config.ts | 14 +- sdk/typescript/src/index.ts | 10 + sdk/typescript/src/project-config-schema.ts | 66 ++-- sdk/typescript/src/project-config.ts | 157 ++++---- sdk/typescript/src/prompt-files.ts | 103 ++++++ sdk/typescript/src/result.ts | 8 + sdk/typescript/src/scan-settings.ts | 71 ++++ sdk/typescript/src/targets.ts | 3 +- sdk/typescript/tests-ts/api.test.ts | 43 ++- .../tests-ts/custom-validation.test.ts | 6 +- .../tests-ts/project-config.test.ts | 53 +-- sdk/typescript/tests-ts/result.test.ts | 18 + .../tests-ts/sdk-project-config.test.ts | 245 +++++++++++++ .../tests-ts/sdk-scan-prompts.test.ts | 99 +++++ 21 files changed, 1081 insertions(+), 391 deletions(-) create mode 100644 sdk/typescript/src/prompt-files.ts create mode 100644 sdk/typescript/tests-ts/sdk-project-config.test.ts create mode 100644 sdk/typescript/tests-ts/sdk-scan-prompts.test.ts diff --git a/docs/project-configuration.md b/docs/project-configuration.md index 1f5586b56..c7782ce16 100644 --- a/docs/project-configuration.md +++ b/docs/project-configuration.md @@ -64,6 +64,87 @@ The file configures scan settings. Patching, PR creation, publication, post-scan actions, and machine-specific plugin or Python selection remain explicit CLI/SDK inputs. +## SDK and CLI contract + +The SDK's `ScanSettings` type is shared by `ScanOptions`, CLI resolution, and +project-file resolution. Existing names stay compatible: + +| Project file | SDK option | CLI flag | +| -------------------------------------- | ------------------------------------- | ------------------------------- | +| `auth` | `auth` | `--auth` | +| `scan.mode` | `mode` | `--mode` | +| `scan.scope.paths` | `target: ["src"]` | `--path` | +| `scan.scope.diff` | `target: DiffTarget.refs(...)` | `--diff`, `--head` | +| `scan.scope.workingTree` | `target: DiffTarget.workingTree(...)` | `--working-tree`, `--base` | +| `scan.knowledgeBase` | `knowledgeBasePaths` | `--knowledge-base` | +| `scan.instructionsFile` | `scanPromptFile` | `--scan-prompt-file` | +| `scan.validationFile` | `validationPromptFile` | `--validation-prompt-file` | +| `scan.deep.workers` | `workers` | `--workers` | +| `scan.deep.subagentsPerWorker` | `subagents` | `--subagents` | +| `scan.deep.stopAfterNoNew` | `stopAfterNoNew` | `--stop-after-no-new` | +| `scan.deep.stopAfterConsecutiveErrors` | `stopAfterConsecutiveErrors` | No flag | +| `scan.deep.maxDiscoveryRuns` | `maxDiscoveryRuns` | `--max-discovery-runs` | +| `scan.deep.maxTimeHours` | `maxTimeHours` | `--max-time-hours` | +| `limits.maxCostUsdPerScan` | `maxCostUsd` | `--max-cost` | +| `policy.failOnSeverity` | `failureSeverity` | `--fail-on-severity` | +| `output.directory` | `outputDir` | `--output-dir` | +| `codex` | Constructor `codexOverrides` | `--codex`, model/provider flags | + +Use an explicit file with `loadProjectConfig()`: + +```ts +import { CodexSecurity, loadProjectConfig } from "@openai/codex-security"; + +const { config, options } = await loadProjectConfig("codex-security.yaml"); +await using security = new CodexSecurity(config); +const result = await security.run(repository, options); +if ( + options.failureSeverity !== undefined && + result.hasFindingsAtOrAbove(options.failureSeverity) +) { + process.exitCode = 1; +} +``` + +For configuration already in memory, pass the same structured object to +`resolveProjectConfig()`: + +```ts +import { + resolveProjectConfig, + type ProjectConfigInput, +} from "@openai/codex-security"; + +const input = { + scan: { mode: "deep", scope: { paths: ["src"] } }, + limits: { maxCostUsdPerScan: 5 }, +} satisfies ProjectConfigInput; +const { config, options } = resolveProjectConfig(input, process.cwd()); +``` + +Both return constructor `config` and scan `options`; the file loader also returns +`projectConfig` path and source metadata. The optional directory argument defaults +to the current directory. It locates a selected file or anchors an in-memory +object's context, prompt, and output paths. Files anchor those paths to their own +directory. Neither helper reads prompt contents or prepares a runtime. + +Use `security.preflight(repository, options)` for the same local checks as CLI +`--dry-run`, including active-mode compatibility and remaining legacy deep +defaults. To override a resolved value or attach a callback, pass +`{ ...options, maxCostUsd: 10, onProgress }` to `run()`. + +SDK callers can use inline `scanPrompt`, `validationPrompt`, and `postScanPrompt`, +or the corresponding `*PromptFile` options. Inline text wins without reading the +matching file. Direct SDK file paths resolve from the current directory and use +the CLI's existing file protections. Post-scan instructions remain explicit +SDK/CLI options and are not part of project files. + +`failureSeverity` records the policy in the scan recipe. The SDK does not throw or +set process status when the threshold is met; call `hasFindingsAtOrAbove()` and +choose the caller's response. The method uses the same ordering as the CLI and +does not filter findings. The CLI/file contract keeps its four reportable levels; +existing SDK calls also accept `informational`. + ## Overrides and paths Settings apply in this order: built-in defaults, applicable legacy deep settings, diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 87d8d9fc6..5a7f8ec29 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -135,22 +135,25 @@ Constructor options: Options for `security.run(repository, options)` and `security.preflight(repository, options)`: -| Option | Description | -| ---------------------------- | ------------------------------------------------------------------------------ | -| `auth` | Credential source: `"auto"`, `"chatgpt"`, or `"api-key"`. | -| `safetyIdentifier` | Stable hashed end-user ID for model requests; requires API-key authentication. | -| `target` | Repository, repository-relative paths, committed diff, or working-tree diff. | -| `mode` | `"standard"` or `"deep"`; deep mode supports repositories and paths. | -| `knowledgeBasePaths` | Architecture documents, security policies, threat models, or directories. | -| `outputDir` | Artifact directory outside the enclosing Git worktree. | -| `archiveExisting` | Archive existing results in `outputDir` before scanning. | -| `maxCostUsd` | Stop when estimated model cost exceeds this positive USD amount. | -| `stopAfterConsecutiveErrors` | Stop deep discovery after this many consecutive errors (default: 3). | -| `maxTimeHours` | Deep-scan discovery limit in hours: greater than zero, up to 96. | -| `failureSeverity` | Finding-severity policy to record in the saved scan recipe. | -| `parentScanId` | Parent scan ID for a rerun. | -| `expectedPluginVersion` | Required original plugin version when replaying a scan. | -| `signal` | `AbortSignal` to cancel a scan. | +| Option | Description | +| ------------------------------------------- | ----------------------------------------------------------------------------------- | +| `auth` | Credential source: `"auto"`, `"chatgpt"`, or `"api-key"`. | +| `safetyIdentifier` | Stable hashed end-user ID for model requests; requires API-key authentication. | +| `target` | Repository, repository-relative paths, committed diff, or working-tree diff. | +| `mode` | `"standard"` or `"deep"`; deep mode supports repositories and paths. | +| `knowledgeBasePaths` | Architecture documents, security policies, threat models, or directories. | +| `scanPrompt` / `scanPromptFile` | Additional scan instructions as text or a local file. | +| `validationPrompt` / `validationPromptFile` | Custom validation instructions as text or a local file; not Deep. | +| `postScanPrompt` / `postScanPromptFile` | Follow-up instructions as text or a local file. | +| `outputDir` | Artifact directory outside the enclosing Git worktree. | +| `archiveExisting` | Archive existing results in `outputDir` before scanning. | +| `maxCostUsd` | Stop when estimated model cost exceeds this positive USD amount. | +| `stopAfterConsecutiveErrors` | Stop deep discovery after this many consecutive errors (default: 3). | +| `maxTimeHours` | Deep-scan discovery limit in hours: greater than zero, up to 96. | +| `failureSeverity` | Severity threshold recorded in the recipe; the SDK caller decides how to handle it. | +| `parentScanId` | Parent scan ID for a rerun. | +| `expectedPluginVersion` | Required original plugin version when replaying a scan. | +| `signal` | `AbortSignal` to cancel a scan. | Follow scans with `onWorkerStatus` and `onReconnect`. `onSessionEvent` receives saved events with thread IDs and worker numbers. Deep scans can additionally use @@ -165,6 +168,40 @@ Deep preflight includes all six resolved deep settings and their origins in `deepScanSources`. Applicable legacy deep configuration is validated during preflight rather than after runtime startup. +`ScanSettings` is the shared settings type. `ScanOptions` adds callbacks, +cancellation, workflow, and runtime controls. Load the same project file used by +`scan -c` through the SDK: + +```ts +import { CodexSecurity, loadProjectConfig } from "@openai/codex-security"; + +const { config, options } = await loadProjectConfig("codex-security.yaml"); +await using security = new CodexSecurity(config); +const result = await security.run(repository, options); +if ( + options.failureSeverity !== undefined && + result.hasFindingsAtOrAbove(options.failureSeverity) +) { + process.exitCode = 1; +} +``` + +`resolveProjectConfig(input, directory?)` accepts a typed `ProjectConfigInput` +object with the same structure as YAML/JSON and returns the same `{ config, +options }` pair. `loadProjectConfig(file, directory?)` resolves the selected file +from `directory`, which defaults to the current directory; paths inside the file +are relative to that file. Object paths are relative to the supplied directory. +Scope paths remain relative to the selected repository. Neither helper starts a +scan, reads prompt contents, or discovers another configuration file. `preflight` +and `run` apply the existing local checks and remaining legacy deep defaults. + +Override resolved SDK options with `{ ...options, maxCostUsd: 5 }`, or add +callbacks there. Direct SDK prompt-file paths use the current directory; inline +text takes precedence over its matching file. Files use the same regular-file +protections as the CLI. The SDK records `failureSeverity` without throwing or +changing process status. `hasFindingsAtOrAbove()` uses the CLI's severity ordering +and leaves the findings unchanged. + ## Authentication Sign in with ChatGPT: @@ -618,7 +655,8 @@ file. Source review still runs; discovery workers do not receive this prompt. npx @openai/codex-security scan . --validation-prompt-file validation.md ``` -The SDK accepts the same text as `validationPrompt`: +The SDK accepts the same file as `validationPromptFile`, or inline text as +`validationPrompt`: ```ts const result = await security.run(repository, { diff --git a/sdk/typescript/scripts/check-package.mjs b/sdk/typescript/scripts/check-package.mjs index 8fb3bb704..7a2587782 100644 --- a/sdk/typescript/scripts/check-package.mjs +++ b/sdk/typescript/scripts/check-package.mjs @@ -186,6 +186,7 @@ const distFiles = new Set( "deep-scan-defaults", "project-config", "project-config-schema", + "prompt-files", "scan-settings", "errors", "github", diff --git a/sdk/typescript/scripts/fixtures/package-consumer.ts b/sdk/typescript/scripts/fixtures/package-consumer.ts index 045ce3076..ea47b084e 100644 --- a/sdk/typescript/scripts/fixtures/package-consumer.ts +++ b/sdk/typescript/scripts/fixtures/package-consumer.ts @@ -3,17 +3,21 @@ import { DiffTarget, deduplicateScan, estimateScanCost, + loadProjectConfig, planComponents, publishScanToCustom, runComponentScans, + resolveProjectConfig, type ComponentScanOptions, type DeduplicateScanResult, type CustomPublicationResult, type Finding, + type ProjectConfigInput, type ScanCost, type ScanOptions, type ScanProgress, type ScanResult, + type ScanSettings, type ValidationOptions, type ValidationResult, } from "@openai/codex-security"; @@ -58,6 +62,25 @@ export async function scan(repository: string): Promise { } } +export function configuredScanOptions(input: ProjectConfigInput): ScanSettings { + return resolveProjectConfig(input).options; +} + +export async function scanFromFile(repository: string, file: string) { + const { config, options } = await loadProjectConfig(file); + await using client = new CodexSecurity(config); + const result = await client.run(repository, { + ...options, + postScanPromptFile: "follow-up.md", + }); + return { + result, + failed: + options.failureSeverity !== undefined && + result.hasFindingsAtOrAbove(options.failureSeverity), + }; +} + export const cost: ScanCost | null = estimateScanCost("gpt-5.6-sol", { input_tokens: 10, output_tokens: 2, diff --git a/sdk/typescript/scripts/smoke-package.mjs b/sdk/typescript/scripts/smoke-package.mjs index 5c8cdc858..498bb6084 100644 --- a/sdk/typescript/scripts/smoke-package.mjs +++ b/sdk/typescript/scripts/smoke-package.mjs @@ -399,7 +399,17 @@ try { [ "--input-type=module", "--eval", - `const sdk = await import(${JSON.stringify(packageManifest.name)}); for (const name of ["CodexSecurity", "publishScan", "publishScanToCustom", "checkScanPublication", "deduplicateScan"]) if (typeof sdk[name] !== "function") throw new Error("The installed package does not export " + name + ".");`, + `const sdk = await import(${JSON.stringify(packageManifest.name)}); for (const name of ["CodexSecurity", "publishScan", "publishScanToCustom", "checkScanPublication", "deduplicateScan", "loadProjectConfig", "resolveProjectConfig"]) if (typeof sdk[name] !== "function") throw new Error("The installed package does not export " + name + "."); + const assert = await import("node:assert/strict"); + const { writeFile } = await import("node:fs/promises"); + const input = { scan: { mode: "deep", deep: { subagentsPerWorker: 0 } }, policy: { failOnSeverity: "high" } }; + await writeFile("scan.json", JSON.stringify(input)); + const loaded = await sdk.loadProjectConfig("scan.json"); + const resolved = sdk.resolveProjectConfig(input); + assert.deepEqual(loaded.config, resolved.config); + assert.deepEqual(loaded.options, resolved.options); + assert.equal(loaded.options.subagents, 0); + assert.equal(loaded.options.failureSeverity, "high");`, ], { cwd: consumer }, ); diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index b3ee95a10..2f1d09131 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -69,10 +69,16 @@ import { type ResolvedDeepScanConfig, } from "./deep-config.js"; import { + DEFAULT_SCAN_AUTH, + DEFAULT_SCAN_MODE, SCAN_AUTH_MODES, + ScanSettingsSchema, type DeepScanOptions, type ScanAuthMode, + type ScanSettings, + type ScanPromptSettings, } from "./scan-settings.js"; +import { resolveScanPrompts } from "./prompt-files.js"; export { SCAN_AUTH_MODES } from "./scan-settings.js"; export type { DeepScanOptions, ScanAuthMode } from "./scan-settings.js"; import { @@ -167,7 +173,6 @@ import { resolveRepositoryPath, type NormalizedTarget, type ScanMode, - type ScanTarget, validatedGitEnvironment, validateCommittedDiffCheckout, validateMode, @@ -224,24 +229,14 @@ interface PreparedSession { const DEEP_SCAN_CONFIG_PATH_ENVIRONMENT = "CODEX_SECURITY_DEEP_SCAN_CONFIG_PATH"; -export interface ScanOptions extends DeepScanOptions { +export interface ScanOptions extends ScanSettings { /** Opt into a durable scan -> custom publication -> dedupe workflow. */ workflowId?: string; - auth?: ScanAuthMode; /** Stable, privacy-preserving end-user ID for this scan's model requests. */ safetyIdentifier?: string; - target?: ScanTarget; - mode?: ScanMode; - knowledgeBasePaths?: string[]; - scanPrompt?: string; - validationPrompt?: string; - postScanPrompt?: string; - outputDir?: string; archiveExisting?: boolean; parentScanId?: string; expectedPluginVersion?: string; - failureSeverity?: SeverityLevel; - maxCostUsd?: number; onCost?: (cost: Readonly) => void; onOutputArchived?: (archiveDir: string) => void; onOutputDirReady?: (scanDir: string) => void; @@ -365,6 +360,7 @@ interface LocalScanInputs protectedRoot: string; stateDirectory: string; deepScanConfiguration?: ResolvedDeepScanConfig; + prompts: ScanPromptSettings; } export interface CodexSecurityMetadata { @@ -469,7 +465,11 @@ export class CodexSecurity { { ...options, outputDir: undefined, archiveExisting: false }, signal, ); - options = { ...options, ...local.deepScanConfiguration?.settings }; + options = { + ...options, + ...local.prompts, + ...local.deepScanConfiguration?.settings, + }; const workflow = new FindingWorkflow( workflowId, this.#dependencies.environment, @@ -485,7 +485,7 @@ export class CodexSecurity { options: { ...options, target: options.target ?? "repository", - mode: options.mode ?? "standard", + mode: options.mode ?? DEFAULT_SCAN_MODE, outputDir: options.outputDir === undefined ? undefined @@ -786,7 +786,9 @@ export class CodexSecurity { protectedRoot, stateDirectory, deepScanConfiguration, + prompts, } = await this.#validateLocalInputs(repository, options, signal); + options = { ...options, ...prompts }; checkOpen(); let temporaryRoot: string | undefined; if ( @@ -2338,7 +2340,7 @@ export class CodexSecurity { } if ( options.maxCostUsd !== undefined && - (!Number.isFinite(options.maxCostUsd) || options.maxCostUsd <= 0) + !ScanSettingsSchema.shape.maxCostUsd.safeParse(options.maxCostUsd).success ) { throw new CodexSecurityError( "The scan cost limit must be a positive USD amount.", @@ -2351,12 +2353,13 @@ export class CodexSecurity { validatedGitEnvironment(this.#dependencies.environment); const normalized = await normalizeTarget(repo, requestedTarget, signal); throwIfAborted(signal); - const mode = options.mode ?? "standard"; + const mode = options.mode ?? DEFAULT_SCAN_MODE; validateMode(normalized, mode); - if (options.validationPrompt !== undefined) { + const prompts = await resolveScanPrompts(options, repo); + if (prompts.validationPrompt !== undefined) { if ( - typeof options.validationPrompt !== "string" || - !options.validationPrompt.trim() + typeof prompts.validationPrompt !== "string" || + !prompts.validationPrompt.trim() ) { throw new CodexSecurityError( "The validation prompt must not be empty.", @@ -2404,6 +2407,7 @@ export class CodexSecurity { outputDir: requestedOutput, protectedRoot, stateDirectory, + prompts, ...(mode === "deep" ? { deepScanConfiguration: await resolveDeepScanConfig( @@ -3167,7 +3171,7 @@ async function collectResult( export function scanAuthentication( environment: ProcessEnvironment, - auth: ScanAuthMode = "auto", + auth: ScanAuthMode = DEFAULT_SCAN_AUTH, modelProvider?: unknown, ): ScanAuthentication { if (!SCAN_AUTH_MODES.includes(auth)) { diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 81f1abb5f..c7662025a 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -13,7 +13,6 @@ import { existsSync, lstatSync, realpathSync, - type BigIntStats, writeSync, } from "node:fs"; import { @@ -21,7 +20,6 @@ import { lstat, mkdir, mkdtemp, - open, readFile, realpath, rm, @@ -36,7 +34,6 @@ import { parse, relative, resolve, - sep, win32, } from "node:path"; import { cwd } from "node:process"; @@ -95,6 +92,11 @@ import { type JsonValue, } from "./config.js"; import { formatUsd, type ScanCost } from "./cost.js"; +import { + isOutsidePath, + readRegularInputFile, + resolveScanPrompts, +} from "./prompt-files.js"; import { CodexSecurityError, ConfigurationError, @@ -181,15 +183,24 @@ import { } from "./version.js"; import { - loadProjectConfig, - resolveProjectConfig, + readProjectConfig, + resolveScanSettings, + projectScopeTarget, type ProjectConfigProvenance, - type ScanSettings, } from "./project-config.js"; +import type { ProjectScope } from "./project-config-schema.js"; import { DEEP_SCAN_SETTINGS, DeepScanSettingsSchema, + DEFAULT_SCAN_AUTH, + FailureSeveritySchema, REPORTABLE_SEVERITIES, + SCAN_SEVERITIES, + ScanSettingsSchema, + scanSettings, + meetsSeverity, + type FailureSeverity, + type ResolvedScanSettings, } from "./scan-settings.js"; const PROGRESS_REFRESH_MILLISECONDS = 1_000; @@ -214,12 +225,8 @@ type Writable = Pick & { readonly columns?: number; }; type SignalName = "SIGINT" | "SIGTERM"; -type FailureSeverity = Exclude; -const DISPLAY_SEVERITIES: readonly SeverityLevel[] = [ - ...REPORTABLE_SEVERITIES, - "informational", -]; +const DISPLAY_SEVERITIES: readonly SeverityLevel[] = SCAN_SEVERITIES; const MODEL_REASONING_EFFORTS = [ "minimal", "low", @@ -862,102 +869,17 @@ const DEEP_SCAN_OPTION_SCHEMAS = { maxTimeHours: DeepScanSettingsSchema.shape.maxTimeHours, }; -async function readPromptFiles( - directory: string, - scanPromptFile?: string, - postScanPromptFile?: string, - repository = directory, - validationPromptFile?: string, -): Promise< - Pick -> { - const [scanPrompt, postScanPrompt, validationPrompt] = await Promise.all([ - scanPromptFile === undefined - ? undefined - : readRegularInputFile( - resolveCliPath(directory, scanPromptFile), - repository, - ), - postScanPromptFile === undefined - ? undefined - : readRegularInputFile( - resolveCliPath(directory, postScanPromptFile), - repository, - ), - validationPromptFile === undefined - ? undefined - : readRegularInputFile( - resolveCliPath(directory, validationPromptFile), - repository, - ), - ]); - if (validationPrompt !== undefined && !validationPrompt.trim()) { - throw new CodexSecurityError("The validation prompt must not be empty."); - } - return { - ...(scanPrompt?.trim() ? { scanPrompt } : {}), - ...(validationPrompt === undefined ? {} : { validationPrompt }), - ...(postScanPrompt?.trim() ? { postScanPrompt } : {}), - }; -} - -async function readRegularInputFile( - path: string, - repository: string, - metadata?: Pick, -): Promise { - const selected = metadata ?? (await lstat(path, { bigint: true })); - if (!selected.isFile()) { - throw new CodexSecurityError("Input files must be regular files."); - } - const canonicalRepository = await realpath(repository); - const canonicalParent = await realpath(dirname(path)); - if (isOutsidePath(relative(canonicalRepository, canonicalParent))) { - for (let ancestor = dirname(path); ; ancestor = dirname(ancestor)) { - if ( - !isOutsidePath(relative(canonicalRepository, await realpath(ancestor))) - ) { - throw new CodexSecurityError( - "Input files must not follow repository directory links outside the selected repository.", - ); - } - if (dirname(ancestor) === ancestor) { - break; - } - } - } - const file = await open( - join(canonicalParent, basename(path)), - constants.O_RDONLY | - (constants.O_NOFOLLOW ?? 0) | - (constants.O_NONBLOCK ?? 0), - ); - try { - const opened = await file.stat({ bigint: true }); - if ( - !opened.isFile() || - opened.dev !== selected.dev || - opened.ino !== selected.ino - ) { - throw new CodexSecurityError("Input files must remain regular files."); - } - return await file.readFile({ encoding: "utf8" }); - } finally { - await file.close(); - } -} - export function resolveCliPath(directory: string, value: string): string { return resolve(directory, expandHome(value)); } -interface ScanArguments extends ScanSettings { +interface ScanArguments extends ResolvedScanSettings { + codexOverrides: JsonObject; projectConfig?: ProjectConfigProvenance; workflowId?: string; safetyIdentifier?: string; verbose?: boolean; repository?: string; - postScanPromptFile?: string; archiveExisting: boolean; pluginPath?: string; pythonPath?: string; @@ -2776,13 +2698,9 @@ export async function main( .describe( "Reuse completed work in the named local findings workflow.", ), - auth: z - .enum(SCAN_AUTH_MODES) - .optional() - .meta({ default: "auto" }) - .describe( - "Select ChatGPT, OPENAI_API_KEY/CODEX_API_KEY, or automatic authentication (default: auto).", - ), + auth: ScanSettingsSchema.shape.auth.describe( + "Select ChatGPT, OPENAI_API_KEY/CODEX_API_KEY, or automatic authentication (default: auto).", + ), verbose: z .boolean() .default(false) @@ -2831,13 +2749,9 @@ export async function main( base: optionValue("--base") .optional() .describe("Git base ref for --working-tree (default: HEAD)."), - mode: z - .enum(["standard", "deep"]) - .optional() - .meta({ default: "standard" }) - .describe( - "Scan mode (default: standard); deep supports repository and path targets.", - ), + mode: ScanSettingsSchema.shape.mode.describe( + "Scan mode (default: standard); deep supports repository and path targets.", + ), ...DEEP_SCAN_OPTION_SCHEMAS, model: optionValue("--model") .optional() @@ -2865,10 +2779,9 @@ export async function main( .array(optionValue("--codex")) .default([]) .describe(CODEX_OVERRIDE_DESCRIPTION), - failOnSeverity: z - .enum(REPORTABLE_SEVERITIES) - .optional() - .describe("Exit 1 for findings at or above LEVEL."), + failOnSeverity: FailureSeveritySchema.optional().describe( + "Exit 1 for findings at or above LEVEL.", + ), patch: z .boolean() .default(false) @@ -2878,11 +2791,9 @@ export async function main( .optional() .describe("Patch findings at or above LEVEL; requires --patch."), createPr: CREATE_PR_OPTION, - maxCost: z - .number() - .positive() - .optional() - .describe("Stop the scan if estimated USD cost exceeds AMOUNT."), + maxCost: ScanSettingsSchema.shape.maxCostUsd.describe( + "Stop the scan if estimated USD cost exceeds AMOUNT.", + ), headless: z .boolean() .default(false) @@ -2954,19 +2865,26 @@ export async function main( const project = options.config === undefined ? undefined - : await loadProjectConfig(options.config, directory); - const { settings, provenance } = resolveProjectConfig( + : await readProjectConfig(options.config, directory); + const scope = resolveCliScope(project?.input.scan?.scope, { + paths: options.path, + diff: options.diff, + workingTree: options.workingTree, + head: options.head, + base: options.base, + }); + const { + config, + options: settings, + projectConfig: provenance, + } = resolveScanSettings( project, { auth: options.auth, - paths: options.path, + target: scope.target, knowledgeBasePaths: options.knowledgeBase, scanPromptFile: options.scanPromptFile, validationPromptFile: options.validationPromptFile, - diff: options.diff, - workingTree: options.workingTree, - head: options.head, - base: options.base, mode: options.mode, workers: options.workers, subagents: options.subagents, @@ -2974,7 +2892,7 @@ export async function main( maxDiscoveryRuns: options.maxDiscoveryRuns, maxTimeHours: options.maxTimeHours, outputDir: options.outputDir, - failOnSeverity: options.failOnSeverity, + failureSeverity: options.failOnSeverity, maxCostUsd: options.maxCost, codexOverrides: parseCodexOverrides( options.codex, @@ -2986,6 +2904,8 @@ export async function main( }, directory, ); + if (provenance !== undefined) + Object.assign(provenance.sources, scope.sources); if (options.archiveExisting && settings.outputDir === undefined) { throw new CodexSecurityError( "--archive-existing requires --output-dir.", @@ -2994,6 +2914,7 @@ export async function main( outcome = await runScan( { ...settings, + codexOverrides: config.codexOverrides, projectConfig: provenance, workflowId: options.workflowId, safetyIdentifier: options.safetyIdentifier, @@ -3380,11 +3301,13 @@ export async function main( knowledgeBasePaths: options.knowledgeBase.map((path) => resolveCliPath(directory, path), ), - ...(await readPromptFiles( - directory, - options.scanPromptFile, - options.postScanPromptFile, + ...(await resolveScanPrompts( + { + scanPromptFile: options.scanPromptFile, + postScanPromptFile: options.postScanPromptFile, + }, repository, + directory, )), ...(options.maxCost === undefined ? {} @@ -3552,12 +3475,14 @@ export async function main( dependencies.addSignalListener("SIGTERM", onTerminate); try { const currentDirectory = dependencies.currentDirectory(); - const prompts = await readPromptFiles( + const prompts = await resolveScanPrompts( + { + scanPromptFile: options.scanPromptFile, + postScanPromptFile: options.postScanPromptFile, + validationPromptFile: options.validationPromptFile, + }, currentDirectory, - options.scanPromptFile, - options.postScanPromptFile, currentDirectory, - options.validationPromptFile, ); let inputPath: string; let outputDir: string; @@ -4624,21 +4549,24 @@ function scanArgumentsFromRecipe( } return { repository, - auth: auth.data, - paths, + auth: auth.data ?? DEFAULT_SCAN_AUTH, + target: + paths.length > 0 + ? paths + : kind === "refs" + ? DiffTarget.refs({ base: reference!, head: head ?? "HEAD" }) + : kind === "working_tree" + ? DiffTarget.workingTree({ base: reference ?? "HEAD" }) + : "repository", knowledgeBasePaths, validationPromptFile, - diff: kind === "refs" ? reference : undefined, - workingTree: kind === "working_tree", - head: kind === "refs" ? head ?? "HEAD" : undefined, - base: kind === "working_tree" ? reference : undefined, mode, ...deepScan.data, archiveExisting: false, codexOverrides: Object.hasOwn(config, "approval_policy") ? config : { ...config, approval_policy: "never" }, - failOnSeverity: threshold as FailureSeverity | undefined, + failureSeverity: threshold as FailureSeverity | undefined, maxCostUsd, dryRun: false, parentScanId, @@ -4913,11 +4841,6 @@ function isFindingIdentifier(value: string): boolean { return /^(?:occ|csf)_[A-Za-z0-9_-]+$/u.test(value); } -function meetsSeverity(finding: Finding, threshold: FailureSeverity): boolean { - const severity = DISPLAY_SEVERITIES.indexOf(finding.severity.level); - return severity >= 0 && severity <= REPORTABLE_SEVERITIES.indexOf(threshold); -} - async function* workbenchFindings( arguments_: readonly string[], dependencies: CliDependencies, @@ -6098,10 +6021,6 @@ function incurErrorMessage(output: string): string { } } -function isOutsidePath(path: string): boolean { - return path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path); -} - async function runExport( arguments_: ExportArguments, output: Writable, @@ -6396,13 +6315,11 @@ async function executeScan( try { const directory = dependencies.currentDirectory(); repository = arguments_.repository ?? directory; - const target = targetFromArguments(arguments_); - const prompts = await readPromptFiles( - directory, - arguments_.scanPromptFile, - arguments_.postScanPromptFile, + const target = arguments_.target; + const prompts = await resolveScanPrompts( + arguments_, resolve(directory, repository), - arguments_.validationPromptFile, + directory, ); const config: CodexSecurityConfig = { pluginPath: arguments_.pluginPath, @@ -6453,14 +6370,14 @@ async function executeScan( mode: arguments_.mode, max_cost_usd: arguments_.maxCostUsd, target: - arguments_.paths.length > 0 + Array.isArray(target) && target.length > 0 ? "paths" - : arguments_.diff !== undefined + : target instanceof DiffTarget && target.kind === "refs" ? "diff" - : arguments_.workingTree + : target instanceof DiffTarget ? "working_tree" : "repository", - requested_auth: auth ?? "auto", + requested_auth: auth ?? DEFAULT_SCAN_AUTH, dry_run: arguments_.dryRun, profile: typeof selectedProfileName === "string" @@ -6537,27 +6454,16 @@ async function executeScan( } security = dependencies.createSecurity(config); const options: ScanOptions = { + ...scanSettings(arguments_), ...(arguments_.workflowId === undefined ? {} : { workflowId: arguments_.workflowId }), auth, safetyIdentifier: arguments_.safetyIdentifier, - target, - knowledgeBasePaths: arguments_.knowledgeBasePaths, ...prompts, - mode: arguments_.mode, - workers: arguments_.workers, - subagents: arguments_.subagents, - stopAfterNoNew: arguments_.stopAfterNoNew, - stopAfterConsecutiveErrors: arguments_.stopAfterConsecutiveErrors, - maxDiscoveryRuns: arguments_.maxDiscoveryRuns, - maxTimeHours: arguments_.maxTimeHours, - outputDir: arguments_.outputDir, archiveExisting: arguments_.archiveExisting, parentScanId: arguments_.parentScanId, expectedPluginVersion: arguments_.expectedPluginVersion, - failureSeverity: arguments_.failOnSeverity, - maxCostUsd: arguments_.maxCostUsd, onCost: (cost) => { diagnostic("cost.updated", { model: cost.model, @@ -6616,7 +6522,7 @@ async function executeScan( onAuthentication: (authentication) => { selectedAuthentication = authentication; diagnostic("authentication.selected", { - requested: auth ?? "auto", + requested: auth ?? DEFAULT_SCAN_AUTH, method: authentication.method, source: authentication.method !== "stored_credentials" @@ -6895,7 +6801,7 @@ async function executeScan( projectConfig: arguments_.projectConfig, scanPromptFile: arguments_.scanPromptFile, validationPromptFile: arguments_.validationPromptFile, - failOnSeverity: arguments_.failOnSeverity, + failOnSeverity: arguments_.failureSeverity, }), }, }; @@ -6908,7 +6814,7 @@ async function executeScan( errorOutput.write("scan completed without a result\n"); return { exitCode: 2, error: "Scan completed without a result." }; } - const threshold = arguments_.failOnSeverity; + const threshold = arguments_.failureSeverity; const findings = result.findings.findings; const actionableFindings = findings.filter((finding) => meetsSeverity(finding, "low"), @@ -7185,8 +7091,11 @@ function scanFailureMessage( } function scanScope(arguments_: ScanArguments): string | null { - if (arguments_.paths.length > 0) { - const displayed = arguments_.paths.slice(0, 3).map((path) => { + const paths: readonly string[] = Array.isArray(arguments_.target) + ? arguments_.target + : []; + if (paths.length > 0) { + const displayed = paths.slice(0, 3).map((path) => { const portable = path.replaceAll("\\", "/"); const scoped = isAbsolute(path) || @@ -7196,10 +7105,12 @@ function scanScope(arguments_: ScanArguments): string | null { : portable; return errorMessage(scoped.replaceAll(/[\u0000-\u001F\u007F]/gu, " ")); }); - return `${displayed.join(", ")}${arguments_.paths.length > displayed.length ? `, +${arguments_.paths.length - displayed.length} more` : ""}`; + return `${displayed.join(", ")}${paths.length > displayed.length ? `, +${paths.length - displayed.length} more` : ""}`; } - if (arguments_.diff !== undefined) return "committed changes"; - if (arguments_.workingTree) return "working-tree changes"; + if (arguments_.target instanceof DiffTarget) + return arguments_.target.kind === "refs" + ? "committed changes" + : "working-tree changes"; return null; } @@ -7444,18 +7355,62 @@ function quoteCliPath(path: string): string { : `'${path.replaceAll("'", `'"'"'`)}'`; } -function targetFromArguments(arguments_: ScanArguments): ScanTarget { - if (arguments_.paths.length > 0) return arguments_.paths; - if (arguments_.diff !== undefined) { - return DiffTarget.refs({ - base: arguments_.diff, - head: arguments_.head ?? "HEAD", - }); - } - if (arguments_.workingTree) { - return DiffTarget.workingTree({ base: arguments_.base ?? "HEAD" }); +function resolveCliScope( + configured: ProjectScope | undefined, + overrides: { + paths?: string[]; + diff?: string; + workingTree?: boolean; + head?: string; + base?: string; + }, +): { target?: ScanTarget; sources: ProjectConfigProvenance["sources"] } { + const sources: ProjectConfigProvenance["sources"] = {}; + const explicitScopes = + Number(!!overrides.paths?.length) + + Number(overrides.diff !== undefined) + + Number(overrides.workingTree === true); + if (explicitScopes > 1) + throw new ConfigurationError( + "--path, --diff, and --working-tree are mutually exclusive.", + ); + let scope = configured; + let changed = false; + sources["scan.scope"] = scope === undefined ? "default" : "project"; + if (overrides.paths?.length) scope = { paths: overrides.paths }; + else if (overrides.diff !== undefined) + scope = { diff: { base: overrides.diff } }; + else if (overrides.workingTree === true) scope = { workingTree: {} }; + else if ( + overrides.workingTree === false && + scope !== undefined && + "workingTree" in scope + ) { + scope = undefined; + changed = true; + } + if (explicitScopes > 0 || changed) { + changed = true; + sources["scan.scope"] = "cli"; + } + if (overrides.head !== undefined) { + if (scope === undefined || !("diff" in scope)) + throw new ConfigurationError("--head requires --diff."); + scope = { diff: { ...scope.diff, head: overrides.head } }; + sources["scan.scope.diff.head"] = "cli"; + changed = true; + } + if (overrides.base !== undefined) { + if (scope === undefined || !("workingTree" in scope)) + throw new ConfigurationError("--base requires --working-tree."); + scope = { workingTree: { base: overrides.base } }; + sources["scan.scope.workingTree.base"] = "cli"; + changed = true; } - return "repository"; + return { + ...(changed ? { target: projectScopeTarget(scope) ?? "repository" } : {}), + sources, + }; } export function parseCodexOverrides( diff --git a/sdk/typescript/src/deep-config.ts b/sdk/typescript/src/deep-config.ts index ff096f0f1..57610740c 100644 --- a/sdk/typescript/src/deep-config.ts +++ b/sdk/typescript/src/deep-config.ts @@ -3,7 +3,12 @@ import { parse as parseToml, type TomlTable } from "smol-toml"; import { writeCodexConfig, type JsonObject } from "./config.js"; import { DEFAULT_DEEP_SCAN_SETTINGS } from "./deep-scan-defaults.js"; import { CodexSecurityError } from "./errors.js"; -import { DEEP_SCAN_SETTINGS, type DeepScanOptions } from "./scan-settings.js"; +import { + DEFAULT_SCAN_MODE, + DEEP_SCAN_SETTINGS, + DeepScanSettingsSchema, + type DeepScanOptions, +} from "./scan-settings.js"; import type { ScanMode } from "./targets.js"; export type DeepScanSources = Record< @@ -25,16 +30,15 @@ export function deepScanOptions( for (const [name, , minimum] of DEEP_SCAN_SETTINGS) { const value = options[name]; if (value === undefined) continue; - if ((options.mode ?? "standard") !== "deep") { + if ((options.mode ?? DEFAULT_SCAN_MODE) !== "deep") { throw new CodexSecurityError("Deep scan settings require deep mode."); } - if (name === "maxTimeHours") { - if (!Number.isFinite(value) || value <= 0 || value > 96) { + if (!DeepScanSettingsSchema.shape[name].safeParse(value).success) { + if (name === "maxTimeHours") { throw new CodexSecurityError( "Deep scan maxTimeHours must be a positive number no greater than 96.", ); } - } else if (!Number.isSafeInteger(value) || value < minimum) { throw new CodexSecurityError( `Deep scan ${name} must be ${minimum === 0 ? "a non-negative" : "a positive"} integer.`, ); diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 29de039dd..9d997a134 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -1,4 +1,14 @@ export { CodexSecurity, createSecurity } from "./api.js"; +export { loadProjectConfig, resolveProjectConfig } from "./project-config.js"; +export type { + ResolvedProjectConfig, + ProjectConfigProvenance, +} from "./project-config.js"; +export type { + ProjectConfigInput, + ProjectScope, +} from "./project-config-schema.js"; +export type { ScanSettings } from "./scan-settings.js"; export { runComponentScans } from "./component-scan.js"; export type { ComponentDeduplicationSummary, diff --git a/sdk/typescript/src/project-config-schema.ts b/sdk/typescript/src/project-config-schema.ts index d9f203597..c62076833 100644 --- a/sdk/typescript/src/project-config-schema.ts +++ b/sdk/typescript/src/project-config-schema.ts @@ -1,8 +1,8 @@ import { z } from "zod"; import { DeepScanSettingsSchema, - REPORTABLE_SEVERITIES, - SCAN_AUTH_MODES, + FailureSeveritySchema, + ScanSettingsSchema, } from "./scan-settings.js"; const nonempty = z.string().min(1); @@ -33,33 +33,24 @@ export const ProjectConfigInputSchema = z.strictObject({ .describe( "Editor schema URI or relative path. The CLI does not fetch or select a validator from this value.", ), - auth: z.enum(SCAN_AUTH_MODES).optional().meta({ - default: "auto", - description: "Credential-source choice only; never a credential value.", - }), + auth: ScanSettingsSchema.shape.auth.describe( + "Credential-source choice only; never a credential value.", + ), scan: z .strictObject({ - mode: z - .enum(["standard", "deep"]) - .optional() - .meta({ default: "standard" }), + mode: ScanSettingsSchema.shape.mode, scope: ProjectScopeSchema.optional().describe( "One scope variant. Omit for the whole repository. Mode compatibility is checked after overrides.", ), - knowledgeBase: z - .array(nonempty) - .optional() - .describe( - "Context files or directories, relative to this file. An empty list selects no additional context.", - ), - instructionsFile: nonempty - .optional() - .describe("Additional scan instructions, relative to this file."), - validationFile: nonempty - .optional() - .describe( - "Custom validation instructions, relative to this file; not supported in active deep scans.", - ), + knowledgeBase: ScanSettingsSchema.shape.knowledgeBasePaths.describe( + "Context files or directories, relative to this file. An empty list selects no additional context.", + ), + instructionsFile: ScanSettingsSchema.shape.scanPromptFile.describe( + "Additional scan instructions, relative to this file.", + ), + validationFile: ScanSettingsSchema.shape.validationPromptFile.describe( + "Custom validation instructions, relative to this file; not supported in active deep scans.", + ), deep: DeepScanSettingsSchema.omit({ subagents: true }) .extend({ subagentsPerWorker: DeepScanSettingsSchema.shape.subagents, @@ -83,32 +74,23 @@ export const ProjectConfigInputSchema = z.strictObject({ ), limits: z .strictObject({ - maxCostUsdPerScan: z - .number() - .positive() - .optional() - .describe( - "Estimated USD limit per launched scan attempt, not a total batch budget. Omit for no limit.", - ), + maxCostUsdPerScan: ScanSettingsSchema.shape.maxCostUsd.describe( + "Estimated USD limit per launched scan attempt, not a total batch budget. Omit for no limit.", + ), }) .optional(), policy: z .strictObject({ - failOnSeverity: z - .enum(REPORTABLE_SEVERITIES) - .optional() - .describe( - "Exit threshold; does not filter retained findings. Omit for report-only behavior.", - ), + failOnSeverity: FailureSeveritySchema.optional().describe( + "Exit threshold; does not filter retained findings. Omit for report-only behavior.", + ), }) .optional(), output: z .strictObject({ - directory: nonempty - .optional() - .describe( - "Artifact directory relative to this file; existing outside-worktree checks still apply.", - ), + directory: ScanSettingsSchema.shape.outputDir.describe( + "Artifact directory relative to this file; existing outside-worktree checks still apply.", + ), }) .optional(), }); diff --git a/sdk/typescript/src/project-config.ts b/sdk/typescript/src/project-config.ts index 035e47de2..05135cc40 100644 --- a/sdk/typescript/src/project-config.ts +++ b/sdk/typescript/src/project-config.ts @@ -15,37 +15,30 @@ import { } from "./project-config-schema.js"; import { expandHome } from "./runtime.js"; import { + DEFAULT_SCAN_AUTH, + DEFAULT_SCAN_MODE, DEEP_SCAN_SETTINGS, + scanSettings, type DeepScanOptions, - type ScanAuthMode, + type ResolvedScanSettings, + type ScanSettings, } from "./scan-settings.js"; -import type { ScanMode } from "./targets.js"; -import type { SeverityLevel } from "./models.js"; +import { DiffTarget, type ScanTarget } from "./targets.js"; const validateProjectConfig = new Ajv({ allErrors: true, }).compile(projectConfigJsonSchema()); -export interface LoadedProjectConfig { - path: string; +export interface ProjectConfigSource { + path?: string; + directory: string; input: ProjectConfigInput; } -export interface ScanSettings extends DeepScanOptions { - auth?: ScanAuthMode; - mode: ScanMode; - paths: string[]; - diff?: string; - workingTree: boolean; - base?: string; - head?: string; - knowledgeBasePaths: string[]; - scanPromptFile?: string; - validationPromptFile?: string; - outputDir?: string; - failOnSeverity?: Exclude; - maxCostUsd?: number; - codexOverrides: JsonObject; +export interface ResolvedProjectConfig { + config: { codexOverrides: JsonObject }; + options: ResolvedScanSettings; + projectConfig?: ProjectConfigProvenance; } export type ConfigurationSource = "default" | "legacy" | "project" | "cli"; @@ -57,7 +50,30 @@ export interface ProjectConfigProvenance { export async function loadProjectConfig( file: string, directory = process.cwd(), -): Promise { +): Promise { + return resolveScanSettings( + await readProjectConfig(file, directory), + {}, + directory, + ); +} + +export function resolveProjectConfig( + input: ProjectConfigInput, + directory = process.cwd(), +): ResolvedProjectConfig { + requireProjectConfig(input); + return resolveScanSettings( + { input, directory: resolve(directory) }, + {}, + directory, + ); +} + +export async function readProjectConfig( + file: string, + directory = process.cwd(), +): Promise { const path = resolve(directory, expandHome(file)); const extension = extname(path).toLowerCase(); if (![".yaml", ".yml", ".json"].includes(extension)) { @@ -89,6 +105,14 @@ export async function loadProjectConfig( { cause: error }, ); } + requireProjectConfig(value, path); + return { path, directory: dirname(path), input: value }; +} + +function requireProjectConfig( + value: unknown, + path?: string, +): asserts value is ProjectConfigInput { if (!validateProjectConfig(value)) { const issues = validateProjectConfig .errors!.map((issue) => { @@ -100,17 +124,16 @@ export async function loadProjectConfig( }) .join("; "); throw new ConfigurationError( - `Invalid project configuration at ${path}: ${issues}`, + `Invalid project configuration${path === undefined ? "" : ` at ${path}`}: ${issues}`, ); } - return { path, input: value }; } -export function resolveProjectConfig( - project: LoadedProjectConfig | undefined, - overrides: Partial, +export function resolveScanSettings( + project: ProjectConfigSource | undefined, + overrides: Partial & { codexOverrides?: JsonObject }, directory: string, -): { settings: ScanSettings; provenance?: ProjectConfigProvenance } { +): ResolvedProjectConfig { const file = project?.input; const sources: Record = { auth: "default", @@ -136,53 +159,24 @@ export function resolveProjectConfig( const filePath = (value: string | undefined): string | undefined => value === undefined ? undefined - : resolve(dirname(project!.path), expandHome(value)); + : resolve(project!.directory, expandHome(value)); const cliPath = (value: string | undefined): string | undefined => value === undefined ? undefined : resolve(directory, expandHome(value)); const mode = - choose("scan.mode", file?.scan?.mode, overrides.mode) ?? "standard"; + choose("scan.mode", file?.scan?.mode, overrides.mode) ?? DEFAULT_SCAN_MODE; if ( mode !== "deep" && DEEP_SCAN_SETTINGS.some(([name]) => overrides[name] !== undefined) ) { throw new ConfigurationError("Deep scan settings require --mode deep."); } - const explicitScopes = - Number(!!overrides.paths?.length) + - Number(overrides.diff !== undefined) + - Number(overrides.workingTree === true); - if (explicitScopes > 1) - throw new ConfigurationError( - "--path, --diff, and --working-tree are mutually exclusive.", - ); - let scope: ProjectScope | undefined = file?.scan?.scope; - if (scope !== undefined) sources["scan.scope"] = "project"; - if (overrides.paths?.length) scope = { paths: overrides.paths }; - else if (overrides.diff !== undefined) - scope = { diff: { base: overrides.diff } }; - else if (overrides.workingTree === true) scope = { workingTree: {} }; - else if ( - overrides.workingTree === false && - scope !== undefined && - "workingTree" in scope - ) { - scope = undefined; - sources["scan.scope"] = "cli"; - } - if (explicitScopes > 0) sources["scan.scope"] = "cli"; - if (overrides.head !== undefined) { - if (scope === undefined || !("diff" in scope)) - throw new ConfigurationError("--head requires --diff."); - scope = { diff: { ...scope.diff, head: overrides.head } }; - sources["scan.scope.diff.head"] = "cli"; - } - if (overrides.base !== undefined) { - if (scope === undefined || !("workingTree" in scope)) - throw new ConfigurationError("--base requires --working-tree."); - scope = { workingTree: { base: overrides.base } }; - sources["scan.scope.workingTree.base"] = "cli"; - } + const target = + choose( + "scan.scope", + projectScopeTarget(file?.scan?.scope), + overrides.target, + ) ?? "repository"; const configuredDeep = file?.scan?.deep; const deep: DeepScanOptions = {}; if (mode === "deep") { @@ -228,17 +222,11 @@ export function resolveProjectConfig( file?.scan?.knowledgeBase?.map((value) => filePath(value)!), overrides.knowledgeBasePaths?.map((value) => cliPath(value)!), ) ?? []; - const settings: ScanSettings = { - auth: choose("auth", file?.auth, overrides.auth) ?? "auto", + const settings: ResolvedScanSettings = { + ...scanSettings(overrides), + auth: choose("auth", file?.auth, overrides.auth) ?? DEFAULT_SCAN_AUTH, mode, - paths: scope !== undefined && "paths" in scope ? [...scope.paths] : [], - workingTree: scope !== undefined && "workingTree" in scope, - ...(scope !== undefined && "diff" in scope - ? { diff: scope.diff.base, head: scope.diff.head ?? "HEAD" } - : {}), - ...(scope !== undefined && "workingTree" in scope - ? { base: scope.workingTree.base ?? "HEAD" } - : {}), + target, knowledgeBasePaths, scanPromptFile: choose( "scan.instructionsFile", @@ -255,23 +243,32 @@ export function resolveProjectConfig( filePath(file?.output?.directory), cliPath(overrides.outputDir), ), - failOnSeverity: choose( + failureSeverity: choose( "policy.failOnSeverity", file?.policy?.failOnSeverity, - overrides.failOnSeverity, + overrides.failureSeverity, ), maxCostUsd: choose( "limits.maxCostUsdPerScan", file?.limits?.maxCostUsdPerScan, overrides.maxCostUsd, ), - codexOverrides, ...deep, }; return { - settings, - ...(project === undefined + config: { codexOverrides }, + options: settings, + ...(project?.path === undefined ? {} - : { provenance: { path: project.path, sources } }), + : { projectConfig: { path: project.path, sources } }), }; } + +export function projectScopeTarget( + scope: ProjectScope | undefined, +): ScanTarget | undefined { + if (scope === undefined) return undefined; + if ("paths" in scope) return [...scope.paths]; + if ("diff" in scope) return DiffTarget.refs(scope.diff); + return DiffTarget.workingTree(scope.workingTree); +} diff --git a/sdk/typescript/src/prompt-files.ts b/sdk/typescript/src/prompt-files.ts new file mode 100644 index 000000000..d737b0580 --- /dev/null +++ b/sdk/typescript/src/prompt-files.ts @@ -0,0 +1,103 @@ +import { constants, type BigIntStats } from "node:fs"; +import { lstat, open, realpath } from "node:fs/promises"; +import { + basename, + dirname, + isAbsolute, + join, + relative, + resolve, + sep, +} from "node:path"; +import { CodexSecurityError } from "./errors.js"; +import { expandHome } from "./runtime.js"; +import type { ScanPromptSettings } from "./scan-settings.js"; + +/** Resolve selected files once; an inline SDK prompt overrides its file. */ +export async function resolveScanPrompts( + options: ScanPromptSettings, + repository: string, + directory = process.cwd(), +): Promise { + const read = async (inline: string | undefined, file: string | undefined) => + inline !== undefined || file === undefined + ? inline + : await readRegularInputFile( + resolve(directory, expandHome(file)), + repository, + ); + const [scanPrompt, validationPrompt, postScanPrompt] = await Promise.all([ + read(options.scanPrompt, options.scanPromptFile), + read(options.validationPrompt, options.validationPromptFile), + read(options.postScanPrompt, options.postScanPromptFile), + ]); + if ( + options.validationPrompt === undefined && + validationPrompt !== undefined && + !validationPrompt.trim() + ) { + throw new CodexSecurityError("The validation prompt must not be empty."); + } + return { + scanPrompt: + options.scanPrompt !== undefined || scanPrompt?.trim() + ? scanPrompt + : undefined, + validationPrompt, + postScanPrompt: + options.postScanPrompt !== undefined || postScanPrompt?.trim() + ? postScanPrompt + : undefined, + scanPromptFile: undefined, + validationPromptFile: undefined, + postScanPromptFile: undefined, + }; +} + +export async function readRegularInputFile( + path: string, + repository: string, + metadata?: Pick, +): Promise { + const selected = metadata ?? (await lstat(path, { bigint: true })); + if (!selected.isFile()) { + throw new CodexSecurityError("Input files must be regular files."); + } + const canonicalRepository = await realpath(repository); + const canonicalParent = await realpath(dirname(path)); + if (isOutsidePath(relative(canonicalRepository, canonicalParent))) { + for (let ancestor = dirname(path); ; ancestor = dirname(ancestor)) { + if ( + !isOutsidePath(relative(canonicalRepository, await realpath(ancestor))) + ) { + throw new CodexSecurityError( + "Input files must not follow repository directory links outside the selected repository.", + ); + } + if (dirname(ancestor) === ancestor) break; + } + } + const file = await open( + join(canonicalParent, basename(path)), + constants.O_RDONLY | + (constants.O_NOFOLLOW ?? 0) | + (constants.O_NONBLOCK ?? 0), + ); + try { + const opened = await file.stat({ bigint: true }); + if ( + !opened.isFile() || + opened.dev !== selected.dev || + opened.ino !== selected.ino + ) { + throw new CodexSecurityError("Input files must remain regular files."); + } + return await file.readFile({ encoding: "utf8" }); + } finally { + await file.close(); + } +} + +export function isOutsidePath(path: string): boolean { + return path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path); +} diff --git a/sdk/typescript/src/result.ts b/sdk/typescript/src/result.ts index e871e23c8..c4efd8bdf 100644 --- a/sdk/typescript/src/result.ts +++ b/sdk/typescript/src/result.ts @@ -5,8 +5,10 @@ import type { Finding, FindingsDocument, ScanManifest, + SeverityLevel, } from "./models.js"; import { estimateScanCost, type ScanCost } from "./cost.js"; +import { meetsSeverity } from "./scan-settings.js"; export interface TurnResultMetadata { id?: string; @@ -111,6 +113,12 @@ export class ScanResult { return join(this.scanDir, "artifacts"); } + public hasFindingsAtOrAbove(threshold: SeverityLevel): boolean { + return this.findings.findings.some((finding) => + meetsSeverity(finding, threshold), + ); + } + public toJSON(): Record { return { manifest: this.manifest, diff --git a/sdk/typescript/src/scan-settings.ts b/sdk/typescript/src/scan-settings.ts index 87d08ee1c..883b7c772 100644 --- a/sdk/typescript/src/scan-settings.ts +++ b/sdk/typescript/src/scan-settings.ts @@ -1,14 +1,26 @@ import { z } from "zod"; import { DEFAULT_DEEP_SCAN_SETTINGS } from "./deep-scan-defaults.js"; +import type { Finding, SeverityLevel } from "./models.js"; +import type { ScanTarget } from "./targets.js"; export const SCAN_AUTH_MODES = ["auto", "chatgpt", "api-key"] as const; export type ScanAuthMode = (typeof SCAN_AUTH_MODES)[number]; +export const SCAN_MODES = ["standard", "deep"] as const; +export type ScanMode = (typeof SCAN_MODES)[number]; +export const DEFAULT_SCAN_AUTH = "auto"; +export const DEFAULT_SCAN_MODE = "standard"; export const REPORTABLE_SEVERITIES = [ "critical", "high", "medium", "low", ] as const; +export const SCAN_SEVERITIES = [ + ...REPORTABLE_SEVERITIES, + "informational", +] as const; +export const FailureSeveritySchema = z.enum(REPORTABLE_SEVERITIES); +export type FailureSeverity = z.infer; export const DEEP_SCAN_SETTINGS = [ ["workers", "workers", 1], @@ -48,3 +60,62 @@ export const DeepScanSettingsSchema = z.strictObject({ }); export type DeepScanOptions = z.infer; + +const inputPath = z.string().min(1); + +export const ScanSettingsSchema = DeepScanSettingsSchema.extend({ + auth: z.enum(SCAN_AUTH_MODES).optional().meta({ default: DEFAULT_SCAN_AUTH }), + mode: z.enum(SCAN_MODES).optional().meta({ default: DEFAULT_SCAN_MODE }), + knowledgeBasePaths: z.array(inputPath).optional(), + scanPrompt: z.string().optional(), + scanPromptFile: inputPath.optional(), + validationPrompt: z.string().optional(), + validationPromptFile: inputPath.optional(), + postScanPrompt: z.string().optional(), + postScanPromptFile: inputPath.optional(), + outputDir: inputPath.optional(), + failureSeverity: z.enum(SCAN_SEVERITIES).optional(), + maxCostUsd: z.number().positive().optional(), +}); + +/** Scan settings shared by SDK calls, CLI flags, and project files. */ +export interface ScanSettings extends z.infer { + target?: ScanTarget; +} + +export interface ResolvedScanSettings extends ScanSettings { + auth: ScanAuthMode; + mode: ScanMode; + target: ScanTarget; + knowledgeBasePaths: string[]; +} + +export type ScanPromptSettings = Pick< + ScanSettings, + | "scanPrompt" + | "scanPromptFile" + | "validationPrompt" + | "validationPromptFile" + | "postScanPrompt" + | "postScanPromptFile" +>; + +export function scanSettings(settings: ScanSettings): ScanSettings { + const keys = [ + "target", + ...Object.keys(ScanSettingsSchema.shape), + ] as (keyof ScanSettings)[]; + return Object.fromEntries( + keys + .filter((key) => settings[key] !== undefined) + .map((key) => [key, settings[key]]), + ) as ScanSettings; +} + +export function meetsSeverity( + finding: Pick, + threshold: SeverityLevel, +): boolean { + const severity = SCAN_SEVERITIES.indexOf(finding.severity.level); + return severity >= 0 && severity <= SCAN_SEVERITIES.indexOf(threshold); +} diff --git a/sdk/typescript/src/targets.ts b/sdk/typescript/src/targets.ts index 032cf9317..74256ffa7 100644 --- a/sdk/typescript/src/targets.ts +++ b/sdk/typescript/src/targets.ts @@ -7,6 +7,8 @@ import { promisify } from "node:util"; import { InvalidTargetError } from "./errors.js"; import { resolveTrustedExecutable } from "./trusted-executable.js"; import { windowsUnsafePathComponent } from "./windows-path.js"; +import type { ScanMode } from "./scan-settings.js"; +export type { ScanMode } from "./scan-settings.js"; const execFile = promisify(execFileCallback); const UNSUPPORTED_GIT_ENVIRONMENT = new Set([ @@ -30,7 +32,6 @@ const GIT_REPOSITORY_ENVIRONMENT = new Set([ "GIT_SHALLOW_FILE", ]); -export type ScanMode = "standard" | "deep"; export type DiffTargetKind = "refs" | "working_tree"; export interface DiffTargetOptions { diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 9a1ce5fe9..9bdf0750d 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -80,7 +80,7 @@ const { cleanup, copyCompletedScan, temporaryDirectory } = createApiTestFixtures(); afterEach(cleanup); -test.each(["completed", "receipt-lost", "scan-interrupted"])( +test.each(["completed", "receipt-lost", "scan-interrupted", "prompt-files"])( "durable scan workflow resumes after %s without rerunning completed work", async (scenario) => { const root = await temporaryDirectory(); @@ -94,6 +94,9 @@ test.each(["completed", "receipt-lost", "scan-interrupted"])( CODEX_SECURITY_STATE_DIR: join(root, "state"), }; const workflowId = "durable-scan"; + const scanPrompt = "Review synthetic authentication boundaries."; + const promptFile = join(root, "instructions.md"); + if (scenario === "prompt-files") await writeFile(promptFile, scanPrompt); let modelCalls = 0; let completed = false; let loseReceipt = scenario === "receipt-lost"; @@ -119,7 +122,10 @@ test.each(["completed", "receipt-lost", "scan-interrupted"])( loseReceipt = false; throw new Error("Synthetic receipt write failure"); } - return await runWorkbench(options, args, input); + const state = await runWorkbench(options, args, input); + if (scenario === "prompt-files" && payload.action === "begin") + await rm(promptFile); + return state; } if (args[0] === "get-scan") return { @@ -143,8 +149,10 @@ test.each(["completed", "receipt-lost", "scan-interrupted"])( createCodex: () => ({ startThread: () => ({ id: "thread-1", - async runStreamed() { + async runStreamed(input: string) { modelCalls++; + if (scenario === "prompt-files") + expect(input).toContain(scanPrompt); if (scenario === "scan-interrupted" && modelCalls === 1) throw new Error("Synthetic interrupted scan"); await copyCompletedScan(root); @@ -158,8 +166,15 @@ test.each(["completed", "receipt-lost", "scan-interrupted"])( const first = await makeClient(1); let original: Record | undefined; try { - if (scenario === "completed") - original = (await first.run(repository, { workflowId })).toJSON(); + if (scenario === "completed" || scenario === "prompt-files") + original = ( + await first.run(repository, { + workflowId, + ...(scenario === "prompt-files" + ? { scanPromptFile: promptFile } + : {}), + }) + ).toJSON(); else await expect(first.run(repository, { workflowId })).rejects.toThrow( "Synthetic", @@ -169,7 +184,12 @@ test.each(["completed", "receipt-lost", "scan-interrupted"])( } const resumed = await makeClient(2); try { - const result = await resumed.run(repository, { workflowId }); + const replacement = join(root, "replacement-instructions.md"); + if (scenario === "prompt-files") await writeFile(replacement, scanPrompt); + const result = await resumed.run(repository, { + workflowId, + ...(scenario === "prompt-files" ? { scanPromptFile: replacement } : {}), + }); expect(result.manifest.scan.id).toBe("scan_example_001"); if (original) expect(result.toJSON()).toEqual(original); expect(modelCalls).toBe(scenario === "scan-interrupted" ? 2 : 1); @@ -2355,10 +2375,17 @@ describe("CodexSecurity orchestration", () => { }, ); + const scanPromptFile = join(root, "instructions.md"); + const postScanPromptFile = join(root, "follow-up.md"); + await writeFile( + scanPromptFile, + "Focus on authentication and authorization.", + ); + await writeFile(postScanPromptFile, "Draft fixes for confirmed findings."); const scanStartedAt = Date.now(); const result = await client.run(repository, { - scanPrompt: "Focus on authentication and authorization.", - postScanPrompt: "Draft fixes for confirmed findings.", + scanPromptFile, + postScanPromptFile, onScanStarted: () => { scanStarted = true; }, diff --git a/sdk/typescript/tests-ts/custom-validation.test.ts b/sdk/typescript/tests-ts/custom-validation.test.ts index 6da81a99e..d75e11c1f 100644 --- a/sdk/typescript/tests-ts/custom-validation.test.ts +++ b/sdk/typescript/tests-ts/custom-validation.test.ts @@ -352,6 +352,8 @@ describe("custom validation", () => { ); } const workflow = "Run the synthetic validation script, then clean up."; + const workflowFile = join(root, "validation.md"); + if (scenario === "standard") await writeFile(workflowFile, workflow); const falsePositive = { reason: "The fixture is not included in the deployed application.", }; @@ -515,7 +517,9 @@ describe("custom validation", () => { ); try { const pending = client.run(repository, { - validationPrompt: workflow, + ...(scenario === "standard" + ? { validationPromptFile: workflowFile } + : { validationPrompt: workflow }), onActivity: (activity) => activities.push(activity), ...(diff ? { target: DiffTarget.workingTree({}) } : {}), }); diff --git a/sdk/typescript/tests-ts/project-config.test.ts b/sdk/typescript/tests-ts/project-config.test.ts index dd574139a..68be49205 100644 --- a/sdk/typescript/tests-ts/project-config.test.ts +++ b/sdk/typescript/tests-ts/project-config.test.ts @@ -4,8 +4,8 @@ import { join } from "node:path"; import { afterEach, describe, expect, test } from "bun:test"; import Ajv from "ajv"; import { - loadProjectConfig, - resolveProjectConfig, + readProjectConfig, + resolveScanSettings, } from "../src/project-config.js"; import { ProjectConfigInputSchema, @@ -166,8 +166,8 @@ describe("project configuration input contract", () => { "scan:\n deep:\n subagentsPerWorker: 0\ncodex:\n synthetic_setting: ${LITERAL_VALUE}\n", ); await writeFile(json, JSON.stringify(input)); - expect((await loadProjectConfig(yaml)).input).toEqual(input); - expect((await loadProjectConfig(json)).input).toEqual(input); + expect((await readProjectConfig(yaml)).input).toEqual(input); + expect((await readProjectConfig(json)).input).toEqual(input); }); test.each([ @@ -181,12 +181,12 @@ describe("project configuration input contract", () => { const root = await temporaryDirectory(); const path = join(root, name); await writeFile(path, contents); - await expect(loadProjectConfig(path)).rejects.toThrow(); + await expect(readProjectConfig(path)).rejects.toThrow(); }); test("reports a missing selected file", async () => { await expect( - loadProjectConfig("missing.yaml", await temporaryDirectory()), + readProjectConfig("missing.yaml", await temporaryDirectory()), ).rejects.toThrow("Cannot read project configuration"); }); }); @@ -196,6 +196,7 @@ describe("project configuration resolution", () => { const root = await temporaryDirectory(); const project = { path: join(root, "settings", "scan.yaml"), + directory: join(root, "settings"), input: { scan: { scope: { paths: ["src"] }, @@ -206,16 +207,17 @@ describe("project configuration resolution", () => { output: { directory: "../artifacts" }, } satisfies ProjectConfigInput, }; - const { settings, provenance } = resolveProjectConfig( - project, - { - knowledgeBasePaths: ["cli-context.md"], - validationPromptFile: "cli-validate.md", - }, - join(root, "invocation"), - ); + const { options: settings, projectConfig: provenance } = + resolveScanSettings( + project, + { + knowledgeBasePaths: ["cli-context.md"], + validationPromptFile: "cli-validate.md", + }, + join(root, "invocation"), + ); expect(settings).toMatchObject({ - paths: ["src"], + target: ["src"], knowledgeBasePaths: [join(root, "invocation", "cli-context.md")], scanPromptFile: join(root, "settings", "scan.md"), validationPromptFile: join(root, "invocation", "cli-validate.md"), @@ -234,6 +236,7 @@ describe("project configuration resolution", () => { const root = await temporaryDirectory(); const project = { path: join(root, "scan.yaml"), + directory: root, input: { scan: { mode: "deep", deep: { subagentsPerWorker: 3, workers: 8 } }, codex: { @@ -245,7 +248,11 @@ describe("project configuration resolution", () => { }, } satisfies ProjectConfigInput, }; - const { settings, provenance } = resolveProjectConfig( + const { + config, + options: settings, + projectConfig: provenance, + } = resolveScanSettings( project, { subagents: 0, @@ -259,6 +266,8 @@ describe("project configuration resolution", () => { expect(settings).toMatchObject({ subagents: 0, workers: 8, + }); + expect(config).toEqual({ codexOverrides: { model: "gpt-5.6-sol", profile: "review", @@ -278,16 +287,16 @@ describe("project configuration resolution", () => { const root = await temporaryDirectory(); const project = { path: join(root, "scan.yaml"), + directory: root, input: { scan: { mode: "deep", deep: { workers: 8 } }, } satisfies ProjectConfigInput, }; expect( - resolveProjectConfig(project, { mode: "standard" }, root).settings - .workers, + resolveScanSettings(project, { mode: "standard" }, root).options.workers, ).toBeUndefined(); expect(() => - resolveProjectConfig(project, { mode: "standard", workers: 2 }, root), + resolveScanSettings(project, { mode: "standard", workers: 2 }, root), ).toThrow("require --mode deep"); }); @@ -298,8 +307,8 @@ describe("project configuration resolution", () => { ["scan.yaml", "codex:\n __proto__:\n syntheticPollution: true\n"], ] as const) { await writeFile(join(root, filename), contents); - const project = await loadProjectConfig(filename, root); - expect(() => resolveProjectConfig(project, {}, root)).toThrow( + const project = await readProjectConfig(filename, root); + expect(() => resolveScanSettings(project, {}, root)).toThrow( "Invalid Codex override key: __proto__.", ); } @@ -317,7 +326,7 @@ describe("project configuration resolution", () => { const root = await temporaryDirectory(); const path = join(root, "scan.json"); await writeFile(path, contents); - await expect(loadProjectConfig(path)).rejects.toThrow( + await expect(readProjectConfig(path)).rejects.toThrow( "Unknown key __proto__.", ); }, diff --git a/sdk/typescript/tests-ts/result.test.ts b/sdk/typescript/tests-ts/result.test.ts index e8cb7d592..81b0970cb 100644 --- a/sdk/typescript/tests-ts/result.test.ts +++ b/sdk/typescript/tests-ts/result.test.ts @@ -3,6 +3,7 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { ScanResult } from "../src/index.js"; +import { fakeResult } from "./cli-fixtures.js"; import type { CoverageDocument, FindingsDocument, @@ -50,6 +51,23 @@ const coverage = { } satisfies CoverageDocument; describe("ScanResult", () => { + test("evaluates a severity threshold without filtering findings or changing serialization", () => { + const result = fakeResult(["medium", "informational"]); + const serialized = result.toJSON(); + expect(result.hasFindingsAtOrAbove("high")).toBe(false); + expect(result.hasFindingsAtOrAbove("medium")).toBe(true); + expect(result.hasFindingsAtOrAbove("low")).toBe(true); + expect(fakeResult(["informational"]).hasFindingsAtOrAbove("low")).toBe( + false, + ); + expect( + fakeResult(["informational"]).hasFindingsAtOrAbove("informational"), + ).toBe(true); + expect(fakeResult([]).hasFindingsAtOrAbove("informational")).toBe(false); + expect(result.findings.findings).toHaveLength(2); + expect(result.toJSON()).toEqual(serialized); + }); + test("exposes canonical paths and machine serialization", () => { const repositoryFinding = { findingId: "finding", diff --git a/sdk/typescript/tests-ts/sdk-project-config.test.ts b/sdk/typescript/tests-ts/sdk-project-config.test.ts new file mode 100644 index 000000000..b26192515 --- /dev/null +++ b/sdk/typescript/tests-ts/sdk-project-config.test.ts @@ -0,0 +1,245 @@ +import { mkdir, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, expect, test } from "bun:test"; +import { stringify } from "yaml"; +import { + DiffTarget, + loadProjectConfig, + resolveProjectConfig, + type ProjectConfigInput, + type ScanOptions, + type ScanSettings, +} from "../src/index.js"; +import { main } from "../src/cli.js"; +import { capture, dependencies } from "./cli-fixtures.js"; +import { TestClient } from "./support/api-client.js"; +import { createApiTestFixtures } from "./support/api-events.js"; + +const { cleanup, temporaryDirectory } = createApiTestFixtures(); +afterEach(cleanup); + +test.each(["standard", "deep"] as const)( + "%s settings agree across SDK options, typed configuration, YAML, JSON, and CLI flags", + async (mode) => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const directory = join(root, "settings"); + const output = join(root, "output"); + await mkdir(join(repository, "src"), { recursive: true }); + await mkdir(directory); + await writeFile(join(directory, "context.md"), "Synthetic context."); + await writeFile(join(directory, "scan.md"), "Synthetic instructions."); + const deep = { + workers: 2, + subagents: 0, + stopAfterNoNew: 3, + stopAfterConsecutiveErrors: 2, + maxDiscoveryRuns: 6, + maxTimeHours: 1.5, + }; + const { subagents, ...fileDeep } = deep; + const input = { + auth: "api-key", + scan: { + mode, + scope: { paths: ["src"] }, + knowledgeBase: ["context.md"], + instructionsFile: "scan.md", + deep: { ...fileDeep, subagentsPerWorker: subagents }, + }, + output: { directory: "../output" }, + limits: { maxCostUsdPerScan: 5 }, + policy: { failOnSeverity: "high" }, + codex: { + profile: "review", + profiles: { + review: { model: "gpt-5.6-terra", model_reasoning_effort: "high" }, + }, + }, + } satisfies ProjectConfigInput; + const original = structuredClone(input); + const options = { + auth: "api-key", + mode, + target: ["src"], + knowledgeBasePaths: [join(directory, "context.md")], + scanPromptFile: join(directory, "scan.md"), + outputDir: output, + maxCostUsd: 5, + failureSeverity: "high", + ...(mode === "deep" ? deep : {}), + } satisfies ScanSettings; + const environment = { + CODEX_HOME: join(root, "ambient"), + CODEX_SECURITY_STATE_DIR: join(root, "state"), + OPENAI_API_KEY: "synthetic-test-key", + }; + const clientDependencies = { + environment, + prepareRuntime: async () => { + throw new Error("Preflight must not initialize a runtime"); + }, + }; + await using direct = new TestClient( + { codexOverrides: input.codex }, + clientDependencies, + ); + const expected = await direct.preflight(repository, options); + const configurations = [resolveProjectConfig(input, directory)]; + const commands: string[][] = []; + for (const extension of ["yaml", "json"]) { + const file = join(directory, `scan.${extension}`); + await writeFile( + file, + extension === "yaml" ? stringify(input) : JSON.stringify(input), + ); + const loaded = await loadProjectConfig(`scan.${extension}`, directory); + expect(loaded.projectConfig?.path).toBe(file); + configurations.push(loaded); + commands.push(["scan", repository, "-c", file]); + } + for (const configuration of configurations) { + expect(configuration.config).toEqual({ codexOverrides: input.codex }); + expect(configuration.options).toEqual({ + ...options, + validationPromptFile: undefined, + }); + await using client = new TestClient( + configuration.config, + clientDependencies, + ); + expect(await client.preflight(repository, configuration.options)).toEqual( + expected, + ); + } + commands.push([ + "scan", + repository, + "--auth", + "api-key", + "--mode", + mode, + "--path", + "src", + "--knowledge-base", + join(directory, "context.md"), + "--scan-prompt-file", + join(directory, "scan.md"), + "--output-dir", + output, + "--max-cost", + "5", + "--fail-on-severity", + "high", + "--codex", + 'profile="review"', + "--codex", + 'profiles.review.model="gpt-5.6-terra"', + "--codex", + 'profiles.review.model_reasoning_effort="high"', + ...(mode === "deep" + ? [ + "--workers", + "2", + "--subagents", + "0", + "--stop-after-no-new", + "3", + "--max-discovery-runs", + "6", + "--max-time-hours", + "1.5", + ] + : []), + ]); + for (const command of commands) { + const stdout = capture(); + const stderr = capture(); + const deps = dependencies({ currentDirectory: root, environment }); + let selected: ScanOptions | undefined; + deps.createSecurity = (config) => { + const client = new TestClient(config, clientDependencies); + return { + run: client.run.bind(client), + close: client.close.bind(client), + preflight: async (repository, options) => { + selected = options; + return await client.preflight(repository, options); + }, + }; + }; + expect( + await main( + [...command, "--dry-run", "--json"], + stdout.stream, + stderr.stream, + deps, + ), + ).toBe(0); + expect(selected).toMatchObject({ + failureSeverity: "high", + scanPrompt: "Synthetic instructions.", + }); + const resolved = JSON.parse(stdout.text()); + // The CLI has no flag for stopAfterConsecutiveErrors; files and SDK calls do. + expect(resolved).toMatchObject({ + ...expected, + ...(mode === "deep" && command[2] !== "-c" + ? { + stopAfterConsecutiveErrors: 3, + deepScanSources: { + ...expected.deepScanSources, + stopAfterConsecutiveErrors: "default", + }, + } + : {}), + ...(command[2] === "-c" ? { failOnSeverity: "high" } : {}), + }); + } + expect(input).toEqual(original); + await expect(stat(output)).rejects.toThrow(); + await expect(stat(environment.CODEX_SECURITY_STATE_DIR)).rejects.toThrow(); + }, +); + +test("typed configuration keeps repository-relative scopes and does not discover files", async () => { + const directory = await temporaryDirectory(); + await writeFile(join(directory, "codex-security.yaml"), "scan: ["); + expect(resolveProjectConfig({}, directory)).toMatchObject({ + config: { codexOverrides: {} }, + options: { + auth: "auto", + mode: "standard", + target: "repository", + knowledgeBasePaths: [], + }, + }); + expect(resolveProjectConfig({}, directory).projectConfig).toBeUndefined(); + for (const [scope, target] of [ + [{ paths: ["src"] }, ["src"]], + [{ diff: { base: "HEAD~1" } }, DiffTarget.refs({ base: "HEAD~1" })], + [{ workingTree: {} }, DiffTarget.workingTree({})], + ] as const) { + expect( + resolveProjectConfig( + { scan: { scope: structuredClone(scope) } } as ProjectConfigInput, + directory, + ).options.target, + ).toEqual(target); + } +}); + +test("public file and object entry points reject the same invalid settings", async () => { + const directory = await temporaryDirectory(); + for (const input of [ + { scan: { workres: 2 } }, + { limits: { maxCostUsdPerScan: 0 } }, + { scan: { deep: { subagentsPerWorker: -1 } } }, + JSON.parse('{"codex":{"__proto__":{"synthetic":true}}}'), + ]) { + const file = join(directory, "scan.json"); + await writeFile(file, JSON.stringify(input)); + expect(() => resolveProjectConfig(input, directory)).toThrow(); + await expect(loadProjectConfig(file)).rejects.toThrow(); + } +}); diff --git a/sdk/typescript/tests-ts/sdk-scan-prompts.test.ts b/sdk/typescript/tests-ts/sdk-scan-prompts.test.ts new file mode 100644 index 000000000..41290ec3a --- /dev/null +++ b/sdk/typescript/tests-ts/sdk-scan-prompts.test.ts @@ -0,0 +1,99 @@ +import { mkdir, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, expect, test } from "bun:test"; +import { TestClient } from "./support/api-client.js"; +import { createApiTestFixtures } from "./support/api-events.js"; + +const { cleanup, temporaryDirectory } = createApiTestFixtures(); +afterEach(cleanup); + +test.each([ + ["scanPromptFile", "scanPrompt"], + ["validationPromptFile", "validationPrompt"], + ["postScanPromptFile", "postScanPrompt"], +] as const)( + "SDK %s uses the existing file protections and permits explicit external files", + async (fileOption, inlineOption) => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const external = join(root, "external"); + const linked = join(repository, "linked"); + await mkdir(repository); + await mkdir(external); + const file = join(external, "prompt.md"); + await writeFile(file, "Synthetic private instructions."); + await symlink( + external, + linked, + process.platform === "win32" ? "junction" : "dir", + ); + await using client = new TestClient( + {}, + { + environment: { CODEX_SECURITY_STATE_DIR: join(root, "state") }, + prepareRuntime: async () => { + throw new Error("Runtime must not start"); + }, + }, + ); + for (const operation of ["preflight", "run"] as const) { + await expect( + client[operation](repository, { [fileOption]: external }), + ).rejects.toThrow("Input files must be regular files"); + await expect( + client[operation](repository, { + [fileOption]: join(linked, "prompt.md"), + }), + ).rejects.toThrow( + "Input files must not follow repository directory links", + ); + } + const preflight = await client.preflight(repository, { + [fileOption]: file, + }); + expect(preflight.mode).toBe("standard"); + expect(JSON.stringify(preflight)).not.toContain( + "Synthetic private instructions", + ); + await expect( + client.preflight(repository, { + [fileOption]: join(root, "missing.md"), + [inlineOption]: "Explicit inline instructions.", + }), + ).resolves.toMatchObject({ mode: "standard" }); + }, +); + +test("SDK prompt files retain empty-file and deep-validation behavior", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + await mkdir(repository); + const empty = join(root, "empty.md"); + const validation = join(root, "validation.md"); + await writeFile(empty, " \n"); + await writeFile(validation, "Validate the synthetic fixture."); + await using client = new TestClient( + {}, + { + environment: { + CODEX_HOME: join(root, "ambient"), + CODEX_SECURITY_STATE_DIR: join(root, "state"), + }, + }, + ); + await expect( + client.preflight(repository, { + scanPromptFile: empty, + postScanPromptFile: empty, + }), + ).resolves.toMatchObject({ mode: "standard" }); + await expect( + client.preflight(repository, { validationPromptFile: empty }), + ).rejects.toThrow("The validation prompt must not be empty"); + await expect( + client.preflight(repository, { + mode: "deep", + validationPromptFile: validation, + }), + ).rejects.toThrow("Custom validation is not supported for Deep scans"); +}); From 3219d7f782e587f03e2e798f9e4dc8a9a04ed016 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 29 Aug 2026 21:08:30 -0700 Subject: [PATCH 05/12] fix(sdk): allow reruns after blank scan prompts Only record additional scan instructions as required when the prompt builder actually includes them. Empty and whitespace-only SDK inputs should remain replayable, matching omitted prompts and empty prompt files. Exercise saved SDK recipes through the CLI rerun path while preserving the requirement to resupply real additional instructions. --- sdk/typescript/src/api.ts | 2 +- .../tests-ts/sdk-scan-prompts.test.ts | 87 ++++++++++++++++++- 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 2f1d09131..eb80249e5 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -1040,7 +1040,7 @@ export class CodexSecurity { deepScanConfiguration?.settings, options.auth, ); - if (options.scanPrompt !== undefined) recipe["requiresScanPrompt"] = true; + if (options.scanPrompt?.trim()) recipe["requiresScanPrompt"] = true; if (options.validationPrompt !== undefined) recipe["validationMode"] = "custom"; const workbenchOptions: WorkbenchCommandOptions = { diff --git a/sdk/typescript/tests-ts/sdk-scan-prompts.test.ts b/sdk/typescript/tests-ts/sdk-scan-prompts.test.ts index 41290ec3a..2cedbb360 100644 --- a/sdk/typescript/tests-ts/sdk-scan-prompts.test.ts +++ b/sdk/typescript/tests-ts/sdk-scan-prompts.test.ts @@ -1,12 +1,93 @@ import { mkdir, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, expect, test } from "bun:test"; -import { TestClient } from "./support/api-client.js"; -import { createApiTestFixtures } from "./support/api-events.js"; +import type { JsonObject } from "../src/index.js"; +import { main } from "../src/cli.js"; +import { capture, dependencies } from "./cli-fixtures.js"; +import { mockWorkbench, TestClient } from "./support/api-client.js"; +import { + completedEvents, + createApiTestFixtures, + preparedRuntime, +} from "./support/api-events.js"; -const { cleanup, temporaryDirectory } = createApiTestFixtures(); +const { cleanup, copyCompletedScan, temporaryDirectory } = + createApiTestFixtures(); afterEach(cleanup); +test.each([ + ["omitted", undefined], + ["empty inline", ""], + ["blank inline", " \n\t"], + ["instructions", "Review the synthetic authorization boundary."], + ["empty file", undefined], +] as const)( + "SDK %s prompts require rerun instructions only when used", + async (scenario, scanPrompt) => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const codexHome = join(root, "codex-home"); + const scanDir = join(root, "scan"); + await mkdir(repository); + await mkdir(codexHome); + await mkdir(scanDir, { mode: 0o700 }); + const file = join(root, "empty.md"); + if (scenario === "empty file") await writeFile(file, " \n"); + let recipe: JsonObject | undefined; + await using client = new TestClient( + {}, + { + environment: { CODEX_SECURITY_STATE_DIR: join(root, "state") }, + prepareRuntime: async () => preparedRuntime(codexHome), + resolvePluginPython: async () => "/managed/python", + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => "deadbeef", + runWorkbench: async (_options, args, input) => { + if (args[0] === "register-cli-scan") + recipe = JSON.parse(input!).recipe; + return mockWorkbench(args, input); + }, + createCodex: () => ({ + startThread: () => ({ + id: "thread-1", + async runStreamed() { + await copyCompletedScan(root); + return { events: completedEvents() }; + }, + }), + }), + }, + ); + await client.run( + repository, + scenario === "empty file" ? { scanPromptFile: file } : { scanPrompt }, + ); + expect(recipe).toBeDefined(); + const requiresInstructions = scenario === "instructions"; + let reran = false; + const stderr = capture(); + const exit = await main( + ["scans", "rerun", "saved", "--json"], + capture().stream, + stderr.stream, + dependencies({ + currentDirectory: repository, + onWorkbench: async () => ({ recipe: recipe! }), + onRun: () => { + reran = true; + }, + }), + ); + expect(exit).toBe(requiresInstructions ? 2 : 0); + expect(reran).toBe(!requiresInstructions); + expect(recipe?.["requiresScanPrompt"]).toBe( + requiresInstructions ? true : undefined, + ); + if (requiresInstructions) + expect(stderr.text()).toContain("additional instructions"); + }, +); + test.each([ ["scanPromptFile", "scanPrompt"], ["validationPromptFile", "validationPrompt"], From 52da4db7d0c9e1ad96d74ab1ad88e46dad0aef8f Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 30 Aug 2026 07:38:00 -0700 Subject: [PATCH 06/12] refactor(config): use snake_case project keys --- docs/examples/codex-security.json | 2 +- docs/examples/codex-security.yaml | 2 +- docs/project-configuration.md | 101 ++++++++++-------- sdk/typescript/README.md | 10 +- .../schemas/project-config.schema.json | 36 +++---- .../scripts/fixtures/package-consumer.ts | 12 ++- sdk/typescript/scripts/smoke-package.mjs | 2 +- sdk/typescript/src/cli.ts | 14 +-- sdk/typescript/src/project-config-schema.ts | 24 +++-- sdk/typescript/src/project-config.ts | 28 ++--- .../tests-ts/cli-project-config.test.ts | 39 +++---- .../tests-ts/project-config.test.ts | 73 ++++++++----- .../tests-ts/sdk-project-config.test.ts | 25 +++-- 13 files changed, 212 insertions(+), 156 deletions(-) diff --git a/docs/examples/codex-security.json b/docs/examples/codex-security.json index 4caf41f40..e8059e319 100644 --- a/docs/examples/codex-security.json +++ b/docs/examples/codex-security.json @@ -8,5 +8,5 @@ "model": "gpt-5.6-sol", "model_reasoning_effort": "xhigh" }, - "policy": { "failOnSeverity": "high" } + "policy": { "fail_on_severity": "high" } } diff --git a/docs/examples/codex-security.yaml b/docs/examples/codex-security.yaml index b6a880beb..31e53a7d4 100644 --- a/docs/examples/codex-security.yaml +++ b/docs/examples/codex-security.yaml @@ -7,4 +7,4 @@ codex: model: gpt-5.6-sol model_reasoning_effort: xhigh policy: - failOnSeverity: high + fail_on_severity: high diff --git a/docs/project-configuration.md b/docs/project-configuration.md index c7782ce16..9a129ab74 100644 --- a/docs/project-configuration.md +++ b/docs/project-configuration.md @@ -19,18 +19,20 @@ For a project with a `src` directory: scan: scope: paths: [src] - knowledgeBase: [SECURITY.md, docs/architecture.md] + knowledge_base: [SECURITY.md, docs/architecture.md] codex: model: gpt-5.6-sol model_reasoning_effort: xhigh policy: - failOnSeverity: high + fail_on_severity: high ``` All settings are optional; `{}` uses the existing defaults. No `version` field is needed. Unknown wrapper keys and invalid types are errors. Values are literal: there is no executable configuration, environment interpolation, remote include, -or multiple-file merge. Wrapper `null` values do not reset settings. +or multiple-file merge. Wrapper `null` values do not reset settings. Project-file +keys use `snake_case`, matching native Codex configuration. Keys inside `codex` +keep their native spelling; names and values are not converted. The [YAML example](examples/codex-security.yaml) and equivalent [JSON example](examples/codex-security.json) select this repository's TypeScript @@ -46,19 +48,19 @@ node sdk/typescript/bin/codex-security.mjs scan . -c docs/examples/codex-securit ## Settings -| Field | Meaning | Default | -| -------------------------- | ---------------------------------------------------------------------------- | ---------------------------------- | -| `auth` | Credential source: `auto`, `chatgpt`, or `api-key`; never a credential value | `auto` | -| `scan.mode` | `standard` or `deep` | `standard` | -| `scan.scope` | Exactly one of `paths: [src]`, `diff: {base: HEAD}`, or `workingTree: {}` | Whole repository | -| `scan.knowledgeBase` | Context files or directories | Empty list | -| `scan.instructionsFile` | Additional scan instructions | Unset | -| `scan.validationFile` | Custom validation instructions; incompatible with active deep mode | Built-in validation | -| `scan.deep` | Deep discovery settings shown below | Existing deep defaults | -| `codex` | Native Codex settings and profiles | Existing isolated configuration | -| `limits.maxCostUsdPerScan` | Estimated USD limit for one scan attempt | No limit | -| `policy.failOnSeverity` | Exit threshold: `critical`, `high`, `medium`, or `low` | Report-only | -| `output.directory` | Artifact directory outside the scanned Git worktree | Existing private artifact location | +| Field | Meaning | Default | +| ------------------------------ | ---------------------------------------------------------------------------- | ---------------------------------- | +| `auth` | Credential source: `auto`, `chatgpt`, or `api-key`; never a credential value | `auto` | +| `scan.mode` | `standard` or `deep` | `standard` | +| `scan.scope` | Exactly one of `paths: [src]`, `diff: {base: HEAD}`, or `working_tree: {}` | Whole repository | +| `scan.knowledge_base` | Context files or directories | Empty list | +| `scan.instructions_file` | Additional scan instructions | Unset | +| `scan.validation_file` | Custom validation instructions; incompatible with active deep mode | Built-in validation | +| `scan.deep` | Deep discovery settings shown below | Existing deep defaults | +| `codex` | Native Codex settings and profiles | Existing isolated configuration | +| `limits.max_cost_usd_per_scan` | Estimated USD limit for one scan attempt | No limit | +| `policy.fail_on_severity` | Exit threshold: `critical`, `high`, `medium`, or `low` | Report-only | +| `output.directory` | Artifact directory outside the scanned Git worktree | Existing private artifact location | The file configures scan settings. Patching, PR creation, publication, post-scan actions, and machine-specific plugin or Python selection remain explicit CLI/SDK @@ -67,28 +69,30 @@ inputs. ## SDK and CLI contract The SDK's `ScanSettings` type is shared by `ScanOptions`, CLI resolution, and -project-file resolution. Existing names stay compatible: - -| Project file | SDK option | CLI flag | -| -------------------------------------- | ------------------------------------- | ------------------------------- | -| `auth` | `auth` | `--auth` | -| `scan.mode` | `mode` | `--mode` | -| `scan.scope.paths` | `target: ["src"]` | `--path` | -| `scan.scope.diff` | `target: DiffTarget.refs(...)` | `--diff`, `--head` | -| `scan.scope.workingTree` | `target: DiffTarget.workingTree(...)` | `--working-tree`, `--base` | -| `scan.knowledgeBase` | `knowledgeBasePaths` | `--knowledge-base` | -| `scan.instructionsFile` | `scanPromptFile` | `--scan-prompt-file` | -| `scan.validationFile` | `validationPromptFile` | `--validation-prompt-file` | -| `scan.deep.workers` | `workers` | `--workers` | -| `scan.deep.subagentsPerWorker` | `subagents` | `--subagents` | -| `scan.deep.stopAfterNoNew` | `stopAfterNoNew` | `--stop-after-no-new` | -| `scan.deep.stopAfterConsecutiveErrors` | `stopAfterConsecutiveErrors` | No flag | -| `scan.deep.maxDiscoveryRuns` | `maxDiscoveryRuns` | `--max-discovery-runs` | -| `scan.deep.maxTimeHours` | `maxTimeHours` | `--max-time-hours` | -| `limits.maxCostUsdPerScan` | `maxCostUsd` | `--max-cost` | -| `policy.failOnSeverity` | `failureSeverity` | `--fail-on-severity` | -| `output.directory` | `outputDir` | `--output-dir` | -| `codex` | Constructor `codexOverrides` | `--codex`, model/provider flags | +project-file resolution. Project files and typed `ProjectConfigInput` objects +use `snake_case`; SDK options keep `camelCase`, and CLI flags keep `kebab-case`. +The resolver maps file keys to the existing SDK options: + +| Project file | SDK option | CLI flag | +| ----------------------------------------- | ------------------------------------- | ------------------------------- | +| `auth` | `auth` | `--auth` | +| `scan.mode` | `mode` | `--mode` | +| `scan.scope.paths` | `target: ["src"]` | `--path` | +| `scan.scope.diff` | `target: DiffTarget.refs(...)` | `--diff`, `--head` | +| `scan.scope.working_tree` | `target: DiffTarget.workingTree(...)` | `--working-tree`, `--base` | +| `scan.knowledge_base` | `knowledgeBasePaths` | `--knowledge-base` | +| `scan.instructions_file` | `scanPromptFile` | `--scan-prompt-file` | +| `scan.validation_file` | `validationPromptFile` | `--validation-prompt-file` | +| `scan.deep.workers` | `workers` | `--workers` | +| `scan.deep.subagents_per_worker` | `subagents` | `--subagents` | +| `scan.deep.stop_after_no_new` | `stopAfterNoNew` | `--stop-after-no-new` | +| `scan.deep.stop_after_consecutive_errors` | `stopAfterConsecutiveErrors` | No flag | +| `scan.deep.max_discovery_runs` | `maxDiscoveryRuns` | `--max-discovery-runs` | +| `scan.deep.max_time_hours` | `maxTimeHours` | `--max-time-hours` | +| `limits.max_cost_usd_per_scan` | `maxCostUsd` | `--max-cost` | +| `policy.fail_on_severity` | `failureSeverity` | `--fail-on-severity` | +| `output.directory` | `outputDir` | `--output-dir` | +| `codex` | Constructor `codexOverrides` | `--codex`, model/provider flags | Use an explicit file with `loadProjectConfig()`: @@ -117,7 +121,7 @@ import { const input = { scan: { mode: "deep", scope: { paths: ["src"] } }, - limits: { maxCostUsdPerScan: 5 }, + limits: { max_cost_usd_per_scan: 5 }, } satisfies ProjectConfigInput; const { config, options } = resolveProjectConfig(input, process.cwd()); ``` @@ -185,13 +189,13 @@ scan: mode: deep deep: workers: 4 - subagentsPerWorker: 3 - stopAfterNoNew: 4 - stopAfterConsecutiveErrors: 3 - maxDiscoveryRuns: 40 - maxTimeHours: 96 + subagents_per_worker: 3 + stop_after_no_new: 4 + stop_after_consecutive_errors: 3 + max_discovery_runs: 40 + max_time_hours: 96 limits: - maxCostUsdPerScan: 10 + max_cost_usd_per_scan: 10 ``` These deep settings show the existing defaults, shared with the Python plugin. @@ -205,9 +209,9 @@ still require deep mode. Deep diff scans and custom validation remain unsupporte Counts retain their existing bounds; zero subagents is valid, and discovery time cannot exceed 96 hours. -`maxCostUsdPerScan` has the same meaning as `--max-cost`: an estimated limit for one +`max_cost_usd_per_scan` has the same meaning as `--max-cost`: an estimated limit for one scan attempt. In-flight work may exceed it. It is not a total budget for a batch or -follow-up actions. `failOnSeverity` changes the exit status without filtering the +follow-up actions. `fail_on_severity` changes the exit status without filtering the retained findings. ## Dry run and editor support @@ -222,6 +226,9 @@ With `-c`, the output also includes `projectConfig.path`, per-setting sources, selected instruction/validation file paths, and the finding policy. Native sources identify which layer supplied a key; profile selection still determines the effective model and effort. Raw native configuration and credentials are not dumped. +Source paths use the project-file spelling, such as +`scan.deep.stop_after_no_new`; existing output properties such as `deepScanSources` +keep their names. Help, version, and command schema output do not load project files. Missing, malformed, or invalid selected files exit `2`. Scan exit codes remain `0` for diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 5a7f8ec29..a41d7d6b5 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -187,13 +187,15 @@ if ( ``` `resolveProjectConfig(input, directory?)` accepts a typed `ProjectConfigInput` -object with the same structure as YAML/JSON and returns the same `{ config, +object with the same `snake_case` keys as YAML/JSON and returns the same `{ config, options }` pair. `loadProjectConfig(file, directory?)` resolves the selected file from `directory`, which defaults to the current directory; paths inside the file are relative to that file. Object paths are relative to the supplied directory. Scope paths remain relative to the selected repository. Neither helper starts a scan, reads prompt contents, or discovers another configuration file. `preflight` and `run` apply the existing local checks and remaining legacy deep defaults. +Project-file keys follow Codex's configuration convention; SDK options keep their +existing `camelCase` names, and CLI flags keep `kebab-case`. Override resolved SDK options with `{ ...options, maxCostUsd: 5 }`, or add callbacks there. Direct SDK prompt-file paths use the current directory; inline @@ -330,7 +332,7 @@ codex: model: gpt-5.6-sol model_reasoning_effort: xhigh policy: - failOnSeverity: high + fail_on_severity: high ``` All settings are optional; `{}` uses the existing defaults. JSON files can use a @@ -494,7 +496,7 @@ max_time_hours = 96 ``` CLI and SDK options override these defaults. Project files can use -`scan.deep.stopAfterConsecutiveErrors`, and SDK calls can use +`scan.deep.stop_after_consecutive_errors`, and SDK calls can use `stopAfterConsecutiveErrors`; there is no new CLI flag for it. `--codex` cannot configure this section. Worker and run counts must be positive integers; `subagents` can be zero. Legacy `workers = "auto"` means @@ -506,7 +508,7 @@ At the deadline, discovery stops; the scan combines and returns completed findin `scan --workers` controls discovery workers within one deep scan; `bulk-scan --workers` controls how many repositories are scanned concurrently. -The project-file deep block uses `subagentsPerWorker` for the existing SDK/CLI +The project-file deep block uses `subagents_per_worker` for the existing SDK/CLI `subagents` setting. A valid deep block can remain inactive in standard mode; explicit deep CLI options require deep mode. All six active values are resolved before runtime preparation and saved in new recipes. Complete saved values are diff --git a/sdk/typescript/schemas/project-config.schema.json b/sdk/typescript/schemas/project-config.schema.json index ba957b233..263de9e6f 100644 --- a/sdk/typescript/schemas/project-config.schema.json +++ b/sdk/typescript/schemas/project-config.schema.json @@ -60,7 +60,7 @@ { "type": "object", "properties": { - "workingTree": { + "working_tree": { "type": "object", "properties": { "base": { @@ -72,22 +72,22 @@ "additionalProperties": false } }, - "required": ["workingTree"], + "required": ["working_tree"], "additionalProperties": false } ] }, - "knowledgeBase": { + "knowledge_base": { "description": "Context files or directories, relative to this file. An empty list selects no additional context.", "type": "array", "items": { "type": "string", "minLength": 1 } }, - "instructionsFile": { + "instructions_file": { "description": "Additional scan instructions, relative to this file.", "type": "string", "minLength": 1 }, - "validationFile": { + "validation_file": { "description": "Custom validation instructions, relative to this file; not supported in active deep scans.", "type": "string", "minLength": 1 @@ -103,40 +103,40 @@ "exclusiveMinimum": 0, "maximum": 9007199254740991 }, - "stopAfterNoNew": { + "subagents_per_worker": { + "default": 3, + "description": "Subagents available to each deep-scan worker. Zero is valid.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "stop_after_no_new": { "default": 4, "description": "Stop after this many runs find no new issues.", "type": "integer", "exclusiveMinimum": 0, "maximum": 9007199254740991 }, - "stopAfterConsecutiveErrors": { + "stop_after_consecutive_errors": { "default": 3, "description": "Stop after this many consecutive discovery errors.", "type": "integer", "exclusiveMinimum": 0, "maximum": 9007199254740991 }, - "maxDiscoveryRuns": { + "max_discovery_runs": { "default": 40, "description": "Maximum deep-scan discovery runs.", "type": "integer", "exclusiveMinimum": 0, "maximum": 9007199254740991 }, - "maxTimeHours": { + "max_time_hours": { "default": 96, "description": "Maximum deep-scan discovery hours (default: 96; maximum: 96).", "type": "number", "exclusiveMinimum": 0, "maximum": 96 - }, - "subagentsPerWorker": { - "default": 3, - "description": "Subagents available to each deep-scan worker. Zero is valid.", - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 } }, "additionalProperties": false @@ -157,7 +157,7 @@ "limits": { "type": "object", "properties": { - "maxCostUsdPerScan": { + "max_cost_usd_per_scan": { "description": "Estimated USD limit per launched scan attempt, not a total batch budget. Omit for no limit.", "type": "number", "exclusiveMinimum": 0 @@ -168,7 +168,7 @@ "policy": { "type": "object", "properties": { - "failOnSeverity": { + "fail_on_severity": { "description": "Exit threshold; does not filter retained findings. Omit for report-only behavior.", "type": "string", "enum": ["critical", "high", "medium", "low"] diff --git a/sdk/typescript/scripts/fixtures/package-consumer.ts b/sdk/typescript/scripts/fixtures/package-consumer.ts index ea47b084e..13c0c0576 100644 --- a/sdk/typescript/scripts/fixtures/package-consumer.ts +++ b/sdk/typescript/scripts/fixtures/package-consumer.ts @@ -62,7 +62,17 @@ export async function scan(repository: string): Promise { } } -export function configuredScanOptions(input: ProjectConfigInput): ScanSettings { +export function configuredScanOptions( + input: ProjectConfigInput = { + scan: { + mode: "deep", + scope: { paths: ["src"] }, + deep: { subagents_per_worker: 0, stop_after_consecutive_errors: 2 }, + }, + limits: { max_cost_usd_per_scan: 5 }, + policy: { fail_on_severity: "high" }, + }, +): ScanSettings { return resolveProjectConfig(input).options; } diff --git a/sdk/typescript/scripts/smoke-package.mjs b/sdk/typescript/scripts/smoke-package.mjs index 498bb6084..79477d24b 100644 --- a/sdk/typescript/scripts/smoke-package.mjs +++ b/sdk/typescript/scripts/smoke-package.mjs @@ -402,7 +402,7 @@ try { `const sdk = await import(${JSON.stringify(packageManifest.name)}); for (const name of ["CodexSecurity", "publishScan", "publishScanToCustom", "checkScanPublication", "deduplicateScan", "loadProjectConfig", "resolveProjectConfig"]) if (typeof sdk[name] !== "function") throw new Error("The installed package does not export " + name + "."); const assert = await import("node:assert/strict"); const { writeFile } = await import("node:fs/promises"); - const input = { scan: { mode: "deep", deep: { subagentsPerWorker: 0 } }, policy: { failOnSeverity: "high" } }; + const input = { scan: { mode: "deep", deep: { subagents_per_worker: 0 } }, policy: { fail_on_severity: "high" } }; await writeFile("scan.json", JSON.stringify(input)); const loaded = await sdk.loadProjectConfig("scan.json"); const resolved = sdk.resolveProjectConfig(input); diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index c7662025a..0c9aeedc6 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -6783,10 +6783,10 @@ async function executeScan( }); progress?.stopTimer(); if (arguments_.projectConfig !== undefined) { - for (const [name] of DEEP_SCAN_SETTINGS) { + for (const [name, key] of DEEP_SCAN_SETTINGS) { const source = preflight.deepScanSources?.[name]; if (source === undefined || source === "override") continue; - const field = name === "subagents" ? "subagentsPerWorker" : name; + const field = name === "subagents" ? "subagents_per_worker" : key; arguments_.projectConfig.sources[`scan.deep.${field}`] = source; } } @@ -7380,11 +7380,11 @@ function resolveCliScope( if (overrides.paths?.length) scope = { paths: overrides.paths }; else if (overrides.diff !== undefined) scope = { diff: { base: overrides.diff } }; - else if (overrides.workingTree === true) scope = { workingTree: {} }; + else if (overrides.workingTree === true) scope = { working_tree: {} }; else if ( overrides.workingTree === false && scope !== undefined && - "workingTree" in scope + "working_tree" in scope ) { scope = undefined; changed = true; @@ -7401,10 +7401,10 @@ function resolveCliScope( changed = true; } if (overrides.base !== undefined) { - if (scope === undefined || !("workingTree" in scope)) + if (scope === undefined || !("working_tree" in scope)) throw new ConfigurationError("--base requires --working-tree."); - scope = { workingTree: { base: overrides.base } }; - sources["scan.scope.workingTree.base"] = "cli"; + scope = { working_tree: { base: overrides.base } }; + sources["scan.scope.working_tree.base"] = "cli"; changed = true; } return { diff --git a/sdk/typescript/src/project-config-schema.ts b/sdk/typescript/src/project-config-schema.ts index c62076833..8e9fbe955 100644 --- a/sdk/typescript/src/project-config-schema.ts +++ b/sdk/typescript/src/project-config-schema.ts @@ -21,7 +21,7 @@ export const ProjectScopeSchema = z.union([ }), }), z.strictObject({ - workingTree: z.strictObject({ + working_tree: z.strictObject({ base: nonempty.optional().meta({ default: "HEAD" }), }), }), @@ -42,18 +42,24 @@ export const ProjectConfigInputSchema = z.strictObject({ scope: ProjectScopeSchema.optional().describe( "One scope variant. Omit for the whole repository. Mode compatibility is checked after overrides.", ), - knowledgeBase: ScanSettingsSchema.shape.knowledgeBasePaths.describe( + knowledge_base: ScanSettingsSchema.shape.knowledgeBasePaths.describe( "Context files or directories, relative to this file. An empty list selects no additional context.", ), - instructionsFile: ScanSettingsSchema.shape.scanPromptFile.describe( + instructions_file: ScanSettingsSchema.shape.scanPromptFile.describe( "Additional scan instructions, relative to this file.", ), - validationFile: ScanSettingsSchema.shape.validationPromptFile.describe( + validation_file: ScanSettingsSchema.shape.validationPromptFile.describe( "Custom validation instructions, relative to this file; not supported in active deep scans.", ), - deep: DeepScanSettingsSchema.omit({ subagents: true }) - .extend({ - subagentsPerWorker: DeepScanSettingsSchema.shape.subagents, + deep: z + .strictObject({ + workers: DeepScanSettingsSchema.shape.workers, + subagents_per_worker: DeepScanSettingsSchema.shape.subagents, + stop_after_no_new: DeepScanSettingsSchema.shape.stopAfterNoNew, + stop_after_consecutive_errors: + DeepScanSettingsSchema.shape.stopAfterConsecutiveErrors, + max_discovery_runs: DeepScanSettingsSchema.shape.maxDiscoveryRuns, + max_time_hours: DeepScanSettingsSchema.shape.maxTimeHours, }) .optional() .describe( @@ -74,14 +80,14 @@ export const ProjectConfigInputSchema = z.strictObject({ ), limits: z .strictObject({ - maxCostUsdPerScan: ScanSettingsSchema.shape.maxCostUsd.describe( + max_cost_usd_per_scan: ScanSettingsSchema.shape.maxCostUsd.describe( "Estimated USD limit per launched scan attempt, not a total batch budget. Omit for no limit.", ), }) .optional(), policy: z .strictObject({ - failOnSeverity: FailureSeveritySchema.optional().describe( + fail_on_severity: FailureSeveritySchema.optional().describe( "Exit threshold; does not filter retained findings. Omit for report-only behavior.", ), }) diff --git a/sdk/typescript/src/project-config.ts b/sdk/typescript/src/project-config.ts index 05135cc40..7c9f2d46a 100644 --- a/sdk/typescript/src/project-config.ts +++ b/sdk/typescript/src/project-config.ts @@ -139,7 +139,7 @@ export function resolveScanSettings( auth: "default", "scan.mode": "default", "scan.scope": "default", - "scan.knowledgeBase": "default", + "scan.knowledge_base": "default", }; const choose = ( key: string, @@ -180,8 +180,8 @@ export function resolveScanSettings( const configuredDeep = file?.scan?.deep; const deep: DeepScanOptions = {}; if (mode === "deep") { - for (const [name] of DEEP_SCAN_SETTINGS) { - const field = name === "subagents" ? "subagentsPerWorker" : name; + for (const [name, key] of DEEP_SCAN_SETTINGS) { + const field = name === "subagents" ? "subagents_per_worker" : key; const value = choose( `scan.deep.${field}`, configuredDeep?.[field], @@ -218,8 +218,8 @@ export function resolveScanSettings( recordNativeSources(overrides.codexOverrides ?? {}, "cli"); const knowledgeBasePaths = choose( - "scan.knowledgeBase", - file?.scan?.knowledgeBase?.map((value) => filePath(value)!), + "scan.knowledge_base", + file?.scan?.knowledge_base?.map((value) => filePath(value)!), overrides.knowledgeBasePaths?.map((value) => cliPath(value)!), ) ?? []; const settings: ResolvedScanSettings = { @@ -229,13 +229,13 @@ export function resolveScanSettings( target, knowledgeBasePaths, scanPromptFile: choose( - "scan.instructionsFile", - filePath(file?.scan?.instructionsFile), + "scan.instructions_file", + filePath(file?.scan?.instructions_file), cliPath(overrides.scanPromptFile), ), validationPromptFile: choose( - "scan.validationFile", - filePath(file?.scan?.validationFile), + "scan.validation_file", + filePath(file?.scan?.validation_file), cliPath(overrides.validationPromptFile), ), outputDir: choose( @@ -244,13 +244,13 @@ export function resolveScanSettings( cliPath(overrides.outputDir), ), failureSeverity: choose( - "policy.failOnSeverity", - file?.policy?.failOnSeverity, + "policy.fail_on_severity", + file?.policy?.fail_on_severity, overrides.failureSeverity, ), maxCostUsd: choose( - "limits.maxCostUsdPerScan", - file?.limits?.maxCostUsdPerScan, + "limits.max_cost_usd_per_scan", + file?.limits?.max_cost_usd_per_scan, overrides.maxCostUsd, ), ...deep, @@ -270,5 +270,5 @@ export function projectScopeTarget( if (scope === undefined) return undefined; if ("paths" in scope) return [...scope.paths]; if ("diff" in scope) return DiffTarget.refs(scope.diff); - return DiffTarget.workingTree(scope.workingTree); + return DiffTarget.workingTree(scope.working_tree); } diff --git a/sdk/typescript/tests-ts/cli-project-config.test.ts b/sdk/typescript/tests-ts/cli-project-config.test.ts index cae7a929c..ebc3311a0 100644 --- a/sdk/typescript/tests-ts/cli-project-config.test.ts +++ b/sdk/typescript/tests-ts/cli-project-config.test.ts @@ -47,12 +47,12 @@ test("actual CLI parsing preserves file values when flags are absent", async () scope: { paths: ["src"] }, deep: { workers: 8, - subagentsPerWorker: 0, - stopAfterConsecutiveErrors: 2, + subagents_per_worker: 0, + stop_after_consecutive_errors: 2, }, }, - limits: { maxCostUsdPerScan: 7 }, - policy: { failOnSeverity: "high" }, + limits: { max_cost_usd_per_scan: 7 }, + policy: { fail_on_severity: "high" }, codex: { model: "gpt-5.6-terra", model_reasoning_effort: "high" }, }); let selected: ScanOptions | undefined; @@ -105,11 +105,11 @@ test("CLI values override matching file values, including native objects and lis scan: { mode: "deep", scope: { paths: ["src"] }, - knowledgeBase: ["file-context.md"], - deep: { workers: 8, subagentsPerWorker: 3 }, + knowledge_base: ["file-context.md"], + deep: { workers: 8, subagents_per_worker: 3 }, }, - limits: { maxCostUsdPerScan: 7 }, - policy: { failOnSeverity: "high" }, + limits: { max_cost_usd_per_scan: 7 }, + policy: { fail_on_severity: "high" }, codex: { model: "gpt-5.6-sol", synthetic_setting: { enabled: true, names: ["first"] }, @@ -187,11 +187,11 @@ test.each([ { kind: "refs", base: "HEAD", head: "HEAD" }, ], [ - { workingTree: {} }, + { working_tree: {} }, ["--base", "HEAD~1"], { kind: "working_tree", base: "HEAD~1" }, ], - [{ workingTree: {} }, ["--no-working-tree"], "repository"], + [{ working_tree: {} }, ["--no-working-tree"], "repository"], ] as const)( "resolves scope %j with overrides %j", async (scope, flags, target) => { @@ -254,7 +254,10 @@ test.each([ test("selecting standard mode leaves inactive file deep settings out of the active scan", async () => { const input = await fixture({ - scan: { mode: "deep", deep: { workers: 8, stopAfterConsecutiveErrors: 2 } }, + scan: { + mode: "deep", + deep: { workers: 8, stop_after_consecutive_errors: 2 }, + }, }); let selected: ScanOptions | undefined; expect( @@ -277,7 +280,7 @@ test("selecting standard mode leaves inactive file deep settings out of the acti test("file prompts use the config directory and CLI prompt overrides use the invocation directory", async () => { const input = await fixture({ - scan: { instructionsFile: "scan.md", validationFile: "validate.md" }, + scan: { instructions_file: "scan.md", validation_file: "validate.md" }, }); await writeFile( join(input.configDirectory, "scan.md"), @@ -484,7 +487,7 @@ test("dry-run uses the real SDK without initializing its runtime and reports pro scan: { mode: "deep", scope: { paths: ["src"] }, - deep: { workers: 8, stopAfterConsecutiveErrors: 2 }, + deep: { workers: 8, stop_after_consecutive_errors: 2 }, }, codex: { profile: "review", @@ -493,7 +496,7 @@ test("dry-run uses the real SDK without initializing its runtime and reports pro review: { model: "gpt-5.6-terra", model_reasoning_effort: "high" }, }, }, - policy: { failOnSeverity: "high" }, + policy: { fail_on_severity: "high" }, }); const ambient = join(input.root, "ambient"); await mkdir(join(ambient, "codex-security"), { recursive: true }); @@ -564,9 +567,9 @@ test("dry-run uses the real SDK without initializing its runtime and reports pro path: input.config, sources: { "scan.deep.workers": "project", - "scan.deep.subagentsPerWorker": "cli", - "scan.deep.stopAfterNoNew": "legacy", - "scan.deep.maxTimeHours": "default", + "scan.deep.subagents_per_worker": "cli", + "scan.deep.stop_after_no_new": "legacy", + "scan.deep.max_time_hours": "default", "codex.model": "cli", "codex.profiles.review.model": "project", }, @@ -578,7 +581,7 @@ test("dry-run uses the real SDK without initializing its runtime and reports pro test.each([ [{ output: { directory: "../repository/artifacts" } }, "outside"], [{ codex: { plugins: {} } }, "plugin"], - [{ scan: { mode: "deep", validationFile: "validate.md" } }, "Deep"], + [{ scan: { mode: "deep", validation_file: "validate.md" } }, "Deep"], ] as const)( "project files retain active scan checks: %j", async (config, message) => { diff --git a/sdk/typescript/tests-ts/project-config.test.ts b/sdk/typescript/tests-ts/project-config.test.ts index 68be49205..c972d9595 100644 --- a/sdk/typescript/tests-ts/project-config.test.ts +++ b/sdk/typescript/tests-ts/project-config.test.ts @@ -42,14 +42,14 @@ const cases: [string, unknown, boolean][] = [ { scan: { mode: "deep", - deep: { workers: 4, subagentsPerWorker: 0, maxTimeHours: 96 }, + deep: { workers: 4, subagents_per_worker: 0, max_time_hours: 96 }, }, }, true, ], [ "working tree with an absent base", - { scan: { scope: { workingTree: {} } } }, + { scan: { scope: { working_tree: {} } } }, true, ], [ @@ -57,7 +57,7 @@ const cases: [string, unknown, boolean][] = [ { scan: { scope: { diff: { base: "HEAD" } } } }, true, ], - ["empty context list", { scan: { knowledgeBase: [] } }, true], + ["empty context list", { scan: { knowledge_base: [] } }, true], [ "editor metadata", { $schema: "../schemas/project-config.schema.json" }, @@ -82,11 +82,24 @@ const cases: [string, unknown, boolean][] = [ ], [ "custom validation may be overridden later", - { scan: { mode: "deep", validationFile: "validate.md" } }, + { scan: { mode: "deep", validation_file: "validate.md" } }, true, ], ["unknown wrapper key", { concurrency: 4 }, false], ["unknown scan key", { scan: { workres: 4 } }, false], + [ + "camelCase names are not project-file keys", + { + scan: { + knowledgeBase: [], + deep: { stopAfterNoNew: 4 }, + scope: { workingTree: {} }, + }, + limits: { maxCostUsdPerScan: 5 }, + policy: { failOnSeverity: "high" }, + }, + false, + ], [ "repository selection is not file configuration", { repository: "." }, @@ -104,13 +117,17 @@ const cases: [string, unknown, boolean][] = [ ["zero workers", { scan: { deep: { workers: 0 } } }, false], ["fractional workers", { scan: { deep: { workers: 1.5 } } }, false], ["no string coercion", { scan: { deep: { workers: "4" } } }, false], - ["negative subagents", { scan: { deep: { subagentsPerWorker: -1 } } }, false], + [ + "negative subagents", + { scan: { deep: { subagents_per_worker: -1 } } }, + false, + ], [ "hours above the existing maximum", - { scan: { deep: { maxTimeHours: 97 } } }, + { scan: { deep: { max_time_hours: 97 } } }, false, ], - ["nonpositive cost", { limits: { maxCostUsdPerScan: 0 } }, false], + ["nonpositive cost", { limits: { max_cost_usd_per_scan: 0 } }, false], ["incorrect native model type", { codex: { model: 42 } }, false], ]; @@ -158,12 +175,12 @@ describe("project configuration input contract", () => { const yaml = join(root, "scan.yaml"); const json = join(root, "scan.json"); const input = { - scan: { deep: { subagentsPerWorker: 0 } }, + scan: { deep: { subagents_per_worker: 0 } }, codex: { synthetic_setting: "${LITERAL_VALUE}" }, } satisfies ProjectConfigInput; await writeFile( yaml, - "scan:\n deep:\n subagentsPerWorker: 0\ncodex:\n synthetic_setting: ${LITERAL_VALUE}\n", + "scan:\n deep:\n subagents_per_worker: 0\ncodex:\n synthetic_setting: ${LITERAL_VALUE}\n", ); await writeFile(json, JSON.stringify(input)); expect((await readProjectConfig(yaml)).input).toEqual(input); @@ -200,9 +217,9 @@ describe("project configuration resolution", () => { input: { scan: { scope: { paths: ["src"] }, - knowledgeBase: ["context.md"], - instructionsFile: "scan.md", - validationFile: "validate.md", + knowledge_base: ["context.md"], + instructions_file: "scan.md", + validation_file: "validate.md", }, output: { directory: "../artifacts" }, } satisfies ProjectConfigInput, @@ -224,27 +241,30 @@ describe("project configuration resolution", () => { outputDir: join(root, "artifacts"), }); expect(provenance?.sources).toMatchObject({ - "scan.knowledgeBase": "cli", - "scan.instructionsFile": "project", - "scan.validationFile": "cli", + "scan.knowledge_base": "cli", + "scan.instructions_file": "project", + "scan.validation_file": "cli", "output.directory": "project", }); - expect(project.input.scan.knowledgeBase).toEqual(["context.md"]); + expect(project.input.scan.knowledge_base).toEqual(["context.md"]); }); - test("retains valid false and zero native values and native profile structure", async () => { + test("keeps native key spelling and false/zero values when merging overrides", async () => { const root = await temporaryDirectory(); const project = { path: join(root, "scan.yaml"), directory: root, input: { - scan: { mode: "deep", deep: { subagentsPerWorker: 3, workers: 8 } }, + scan: { mode: "deep", deep: { subagents_per_worker: 3, workers: 8 } }, codex: { - profile: "review", + profile: "reviewCase", profiles: { - review: { model: "gpt-5.6-terra", model_reasoning_effort: "high" }, + reviewCase: { + model: "gpt-5.6-terra", + model_reasoning_effort: "high", + }, }, - synthetic_setting: { enabled: true, count: 2, names: ["first"] }, + synthetic_setting: { enabled: true, itemCount: 2, names: ["first"] }, }, } satisfies ProjectConfigInput, }; @@ -258,7 +278,7 @@ describe("project configuration resolution", () => { subagents: 0, codexOverrides: { model: "gpt-5.6-sol", - synthetic_setting: { enabled: false, count: 0, names: [] }, + synthetic_setting: { enabled: false, itemCount: 0, names: [] }, }, }, root, @@ -270,16 +290,17 @@ describe("project configuration resolution", () => { expect(config).toEqual({ codexOverrides: { model: "gpt-5.6-sol", - profile: "review", + profile: "reviewCase", profiles: project.input.codex.profiles, - synthetic_setting: { enabled: false, count: 0, names: [] }, + synthetic_setting: { enabled: false, itemCount: 0, names: [] }, }, }); expect(provenance?.sources).toMatchObject({ - "scan.deep.subagentsPerWorker": "cli", + "scan.deep.subagents_per_worker": "cli", "scan.deep.workers": "project", "codex.model": "cli", - "codex.profiles.review.model": "project", + "codex.profiles.reviewCase.model": "project", + "codex.synthetic_setting.itemCount": "cli", }); }); diff --git a/sdk/typescript/tests-ts/sdk-project-config.test.ts b/sdk/typescript/tests-ts/sdk-project-config.test.ts index b26192515..cdc095cca 100644 --- a/sdk/typescript/tests-ts/sdk-project-config.test.ts +++ b/sdk/typescript/tests-ts/sdk-project-config.test.ts @@ -37,19 +37,25 @@ test.each(["standard", "deep"] as const)( maxDiscoveryRuns: 6, maxTimeHours: 1.5, }; - const { subagents, ...fileDeep } = deep; const input = { auth: "api-key", scan: { mode, scope: { paths: ["src"] }, - knowledgeBase: ["context.md"], - instructionsFile: "scan.md", - deep: { ...fileDeep, subagentsPerWorker: subagents }, + knowledge_base: ["context.md"], + instructions_file: "scan.md", + deep: { + workers: 2, + subagents_per_worker: 0, + stop_after_no_new: 3, + stop_after_consecutive_errors: 2, + max_discovery_runs: 6, + max_time_hours: 1.5, + }, }, output: { directory: "../output" }, - limits: { maxCostUsdPerScan: 5 }, - policy: { failOnSeverity: "high" }, + limits: { max_cost_usd_per_scan: 5 }, + policy: { fail_on_severity: "high" }, codex: { profile: "review", profiles: { @@ -218,7 +224,7 @@ test("typed configuration keeps repository-relative scopes and does not discover for (const [scope, target] of [ [{ paths: ["src"] }, ["src"]], [{ diff: { base: "HEAD~1" } }, DiffTarget.refs({ base: "HEAD~1" })], - [{ workingTree: {} }, DiffTarget.workingTree({})], + [{ working_tree: {} }, DiffTarget.workingTree({})], ] as const) { expect( resolveProjectConfig( @@ -233,8 +239,9 @@ test("public file and object entry points reject the same invalid settings", asy const directory = await temporaryDirectory(); for (const input of [ { scan: { workres: 2 } }, - { limits: { maxCostUsdPerScan: 0 } }, - { scan: { deep: { subagentsPerWorker: -1 } } }, + { limits: { max_cost_usd_per_scan: 0 } }, + { scan: { deep: { subagents_per_worker: -1 } } }, + { limits: { maxCostUsdPerScan: 5 } }, JSON.parse('{"codex":{"__proto__":{"synthetic":true}}}'), ]) { const file = join(directory, "scan.json"); From d1a465e43a81eb14b1fdeca98a41f5b48a0bf316 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 30 Aug 2026 08:20:40 -0700 Subject: [PATCH 07/12] fix(sdk): leave missing ambient deep config unchanged --- sdk/typescript/src/deep-config.ts | 1 + sdk/typescript/tests-ts/api.test.ts | 42 ++++++++++++++++------------- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/sdk/typescript/src/deep-config.ts b/sdk/typescript/src/deep-config.ts index 57610740c..60424b5eb 100644 --- a/sdk/typescript/src/deep-config.ts +++ b/sdk/typescript/src/deep-config.ts @@ -110,6 +110,7 @@ export async function writeDeepScanConfig( destination: string, resolved: ResolvedDeepScanConfig, ): Promise { + if (destination === resolved.source && !resolved.hasOverrides) return; const [source, target] = await Promise.all([ realpath(resolved.source).catch(() => null), realpath(destination).catch(() => null), diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 9bdf0750d..571e5e276 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -2911,7 +2911,7 @@ describe("CodexSecurity orchestration", () => { }, ); - test.each(["defaults", "complete overrides"])( + test.each(["defaults", "complete overrides", "missing configuration"])( "preserves ambient configuration when the deep-scan runtime uses the same home with %s", async (settings) => { const root = await temporaryDirectory(); @@ -2922,10 +2922,11 @@ describe("CodexSecurity orchestration", () => { const originalConfiguration = "[other]\nenabled = true\n"; await mkdir(repository); await mkdir(join(codexHome, "codex-security"), { recursive: true }); - await writeFile(configPath, originalConfiguration); + if (settings !== "missing configuration") + await writeFile(configPath, originalConfiguration); await mkdir(scanDir, { mode: 0o700 }); - const client = new TestClient( + await using client = new TestClient( {}, { environment: { CODEX_HOME: codexHome }, @@ -2959,23 +2960,28 @@ describe("CodexSecurity orchestration", () => { : {}), }), ).rejects.toThrow("deep scan settings captured"); - const written = await readFile(configPath, "utf8"); - if (settings === "defaults") { - expect(written).toBe(originalConfiguration); - } else { - expect(parseToml(written)).toEqual({ - other: { enabled: true }, - deep_scan: { - workers: 2, - subagents: 0, - stop_after_no_new: 7, - stop_after_consecutive_errors: 2, - max_discovery_runs: 12, - max_time_hours: 1.5, - }, + if (settings === "missing configuration") { + await expect(readFile(configPath)).rejects.toMatchObject({ + code: "ENOENT", }); + } else { + const written = await readFile(configPath, "utf8"); + if (settings === "defaults") { + expect(written).toBe(originalConfiguration); + } else { + expect(parseToml(written)).toEqual({ + other: { enabled: true }, + deep_scan: { + workers: 2, + subagents: 0, + stop_after_no_new: 7, + stop_after_consecutive_errors: 2, + max_discovery_runs: 12, + max_time_hours: 1.5, + }, + }); + } } - await client.close(); }, ); From 6eb26ae6f58ef7ed591ef22d3ce8b563f9c32467 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 30 Aug 2026 09:27:35 -0700 Subject: [PATCH 08/12] fix(sdk): compare normalized ambient config paths --- sdk/typescript/src/deep-config.ts | 7 ++++++- sdk/typescript/tests-ts/api.test.ts | 20 +++++++++++++++----- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/sdk/typescript/src/deep-config.ts b/sdk/typescript/src/deep-config.ts index 60424b5eb..79f30b6b6 100644 --- a/sdk/typescript/src/deep-config.ts +++ b/sdk/typescript/src/deep-config.ts @@ -1,4 +1,5 @@ import { readFile, realpath } from "node:fs/promises"; +import { resolve } from "node:path"; import { parse as parseToml, type TomlTable } from "smol-toml"; import { writeCodexConfig, type JsonObject } from "./config.js"; import { DEFAULT_DEEP_SCAN_SETTINGS } from "./deep-scan-defaults.js"; @@ -110,7 +111,11 @@ export async function writeDeepScanConfig( destination: string, resolved: ResolvedDeepScanConfig, ): Promise { - if (destination === resolved.source && !resolved.hasOverrides) return; + if ( + !resolved.hasOverrides && + resolve(destination) === resolve(resolved.source) + ) + return; const [source, target] = await Promise.all([ realpath(resolved.source).catch(() => null), realpath(destination).catch(() => null), diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index db50ebedd..a75ccb6a5 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -15,7 +15,7 @@ import * as fsPromises from "node:fs/promises"; import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; -import { basename, delimiter, dirname, join, win32 } from "node:path"; +import { basename, delimiter, dirname, join, relative, win32 } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { Codex, @@ -2911,9 +2911,14 @@ describe("CodexSecurity orchestration", () => { }, ); - test.each(["defaults", "complete overrides", "missing configuration"])( - "preserves ambient configuration when the deep-scan runtime uses the same home with %s", - async (settings) => { + test.each([ + ["defaults", "absolute"], + ["complete overrides", "absolute"], + ["missing configuration", "absolute"], + ["missing configuration", "relative"], + ] as const)( + "preserves ambient configuration when the deep-scan runtime uses the same home with %s (%s path)", + async (settings, homeKind) => { const root = await temporaryDirectory(); const repository = join(root, "repository"); const codexHome = join(root, "codex-home"); @@ -2929,7 +2934,12 @@ describe("CodexSecurity orchestration", () => { await using client = new TestClient( {}, { - environment: { CODEX_HOME: codexHome }, + environment: { + CODEX_HOME: + homeKind === "relative" + ? relative(process.cwd(), codexHome) + : codexHome, + }, prepareRuntime: async () => preparedRuntime(codexHome), resolvePluginPython: async () => "/managed/python", prepareOutputDir: async () => scanDir, From 99aec8696668fc11cc503138b2a83e122acc6e32 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 30 Aug 2026 10:07:01 -0700 Subject: [PATCH 09/12] fix(config): preserve path aliases and repeated YAML profiles --- sdk/typescript/src/deep-config.ts | 22 +++++++++----- sdk/typescript/src/project-config.ts | 2 +- sdk/typescript/tests-ts/api.test.ts | 30 ++++++++++++++----- sdk/typescript/tests-ts/deep-config.test.ts | 20 +++++++++++++ .../tests-ts/project-config.test.ts | 24 +++++++++++++++ 5 files changed, 81 insertions(+), 17 deletions(-) diff --git a/sdk/typescript/src/deep-config.ts b/sdk/typescript/src/deep-config.ts index 79f30b6b6..176a50eec 100644 --- a/sdk/typescript/src/deep-config.ts +++ b/sdk/typescript/src/deep-config.ts @@ -1,5 +1,5 @@ import { readFile, realpath } from "node:fs/promises"; -import { resolve } from "node:path"; +import { basename, dirname, join } from "node:path"; import { parse as parseToml, type TomlTable } from "smol-toml"; import { writeCodexConfig, type JsonObject } from "./config.js"; import { DEFAULT_DEEP_SCAN_SETTINGS } from "./deep-scan-defaults.js"; @@ -111,14 +111,9 @@ export async function writeDeepScanConfig( destination: string, resolved: ResolvedDeepScanConfig, ): Promise { - if ( - !resolved.hasOverrides && - resolve(destination) === resolve(resolved.source) - ) - return; const [source, target] = await Promise.all([ - realpath(resolved.source).catch(() => null), - realpath(destination).catch(() => null), + canonicalConfigPath(resolved.source).catch(() => null), + canonicalConfigPath(destination).catch(() => null), ]); let document = resolved.document; if (source !== null && source === target) { @@ -133,6 +128,17 @@ export async function writeDeepScanConfig( } as JsonObject); } +async function canonicalConfigPath(path: string): Promise { + try { + return await realpath(path); + } catch (error) { + const parent = dirname(path); + if ((error as NodeJS.ErrnoException).code !== "ENOENT" || parent === path) + throw error; + return join(await canonicalConfigPath(parent), basename(path)); + } +} + async function readDeepScanDocument( source: string, signal?: AbortSignal, diff --git a/sdk/typescript/src/project-config.ts b/sdk/typescript/src/project-config.ts index 7c9f2d46a..560d9e896 100644 --- a/sdk/typescript/src/project-config.ts +++ b/sdk/typescript/src/project-config.ts @@ -97,7 +97,7 @@ export async function readProjectConfig( } else { const document = parseDocument(text, { prettyErrors: false }); if (document.errors.length > 0) throw document.errors[0]; - value = document.toJS(); + value = document.toJS({ maxAliasCount: -1 }); } } catch (error) { throw new ConfigurationError( diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index a75ccb6a5..077aea3d1 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -2995,9 +2995,15 @@ describe("CodexSecurity orchestration", () => { }, ); - test.skipIf(process.platform !== "win32")( - "preserves ambient configuration when the same Windows home uses different casing", - async () => { + test + .skipIf(process.platform !== "win32") + .each([ + "existing configuration", + "missing configuration", + "missing configuration directory", + ])( + "preserves ambient configuration when the same Windows home uses different casing with %s", + async (state) => { const root = await temporaryDirectory(); const repository = join(root, "repository"); const codexHome = join(root, "codex-home"); @@ -3005,11 +3011,14 @@ describe("CodexSecurity orchestration", () => { const configPath = join(codexHome, "codex-security", "config.toml"); const originalConfiguration = "[other]\nenabled = true\n"; await mkdir(repository); - await mkdir(join(codexHome, "codex-security"), { recursive: true }); - await writeFile(configPath, originalConfiguration); + await mkdir(codexHome); + if (state !== "missing configuration directory") + await mkdir(join(codexHome, "codex-security")); + if (state === "existing configuration") + await writeFile(configPath, originalConfiguration); await mkdir(scanDir, { mode: 0o700 }); - const client = new TestClient( + await using client = new TestClient( {}, { environment: { CODEX_HOME: codexHome.toUpperCase() }, @@ -3031,8 +3040,13 @@ describe("CodexSecurity orchestration", () => { await expect(client.run(repository, { mode: "deep" })).rejects.toThrow( "deep scan settings captured", ); - expect(await readFile(configPath, "utf8")).toBe(originalConfiguration); - await client.close(); + if (state === "existing configuration") { + expect(await readFile(configPath, "utf8")).toBe(originalConfiguration); + } else { + await expect(readFile(configPath)).rejects.toMatchObject({ + code: "ENOENT", + }); + } }, ); diff --git a/sdk/typescript/tests-ts/deep-config.test.ts b/sdk/typescript/tests-ts/deep-config.test.ts index ebb4dd87f..1d825efb3 100644 --- a/sdk/typescript/tests-ts/deep-config.test.ts +++ b/sdk/typescript/tests-ts/deep-config.test.ts @@ -45,6 +45,26 @@ test("resolves all six defaults without creating an ambient file", async () => { }); }); +test.each(["missing file", "missing directory"])( + "preserves absent ambient configuration through a directory link with a %s", + async (state) => { + const input = await fixture(); + if (state === "missing directory") + await rm(dirname(input.source), { recursive: true }); + const home = dirname(dirname(input.source)); + const linkedHome = join(input.root, "linked-home"); + await symlink(home, linkedHome, "junction"); + const source = join(linkedHome, "codex-security", "config.toml"); + await writeDeepScanConfig( + input.source, + await resolveDeepScanConfig({}, source), + ); + await expect(readFile(input.source)).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); + test("normalizes legacy auto and preserves explicit zero while merging", async () => { const input = await fixture( '[deep_scan]\nworkers = "auto"\nsubagents = 2\nstop_after_no_new = 6\nstop_after_consecutive_errors = 5\nmax_time_hours = 1.5\n', diff --git a/sdk/typescript/tests-ts/project-config.test.ts b/sdk/typescript/tests-ts/project-config.test.ts index c972d9595..d10433360 100644 --- a/sdk/typescript/tests-ts/project-config.test.ts +++ b/sdk/typescript/tests-ts/project-config.test.ts @@ -187,6 +187,30 @@ describe("project configuration input contract", () => { expect((await readProjectConfig(json)).input).toEqual(input); }); + test("loads YAML profiles that reuse an anchored table many times", async () => { + const root = await temporaryDirectory(); + const path = join(root, "profiles.yaml"); + const profile = { model: "synthetic-model" }; + const names = Array.from({ length: 150 }, (_, index) => `profile_${index}`); + await writeFile( + path, + [ + "codex:", + " profiles:", + " shared: &shared", + " model: synthetic-model", + ...names.map((name) => ` ${name}: *shared`), + ].join("\n"), + ); + const project = await readProjectConfig(path); + expect( + resolveScanSettings(project, {}, root).config.codexOverrides["profiles"], + ).toEqual({ + shared: profile, + ...Object.fromEntries(names.map((name) => [name, profile])), + }); + }); + test.each([ ["invalid.yaml", "scan: [\n"], ["duplicate.yaml", "scan: {}\nscan: {}\n"], From c847acf3f70a3510f232d357d6924889ce5fd707 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 31 Aug 2026 01:07:41 -0700 Subject: [PATCH 10/12] fix(config): address project configuration review feedback Preserve workflow identity and ambient defaults, restore bounded YAML expansion, and make required scan instructions replaceable on rerun. Complete configuration support for bulk and component scans, add starter generation and inspection, and centralize setting and provenance metadata. Document the operator trust boundary and verify the installed Node package. --- docs/examples/codex-security.json | 2 +- docs/examples/codex-security.yaml | 3 +- docs/project-configuration.md | 95 +++- sdk/typescript/README.md | 82 ++- sdk/typescript/package.json | 2 +- sdk/typescript/scripts/check-package.mjs | 2 + .../generate-project-config-schema.mjs | 38 +- sdk/typescript/scripts/smoke-package.mjs | 26 +- sdk/typescript/src/api.ts | 313 ++++++----- sdk/typescript/src/bulk-scan-discovery.ts | 3 +- sdk/typescript/src/cli.ts | 432 ++++++++++----- sdk/typescript/src/component-scan.ts | 21 +- sdk/typescript/src/config-path.ts | 12 + sdk/typescript/src/deep-config.ts | 122 +++-- sdk/typescript/src/index.ts | 1 + sdk/typescript/src/multiscan.ts | 45 +- sdk/typescript/src/project-config.ts | 161 ++++-- sdk/typescript/src/prompt-files.ts | 13 +- sdk/typescript/src/result.ts | 3 +- sdk/typescript/src/scan-modes.ts | 2 + sdk/typescript/src/scan-settings.ts | 93 +++- sdk/typescript/src/targets.ts | 5 +- sdk/typescript/tests-ts/api.test.ts | 55 ++ .../tests-ts/cli-project-config.test.ts | 500 +++++++++++++++++- sdk/typescript/tests-ts/cli.test.ts | 31 +- sdk/typescript/tests-ts/deep-config.test.ts | 65 +++ .../tests-ts/project-config.test.ts | 70 ++- sdk/typescript/tests-ts/result.test.ts | 9 + .../tests-ts/sdk-project-config.test.ts | 2 +- sdk/typescript/tests-ts/skeleton.test.ts | 4 + 30 files changed, 1754 insertions(+), 458 deletions(-) create mode 100644 sdk/typescript/src/config-path.ts create mode 100644 sdk/typescript/src/scan-modes.ts diff --git a/docs/examples/codex-security.json b/docs/examples/codex-security.json index e8059e319..84eda53b6 100644 --- a/docs/examples/codex-security.json +++ b/docs/examples/codex-security.json @@ -1,5 +1,5 @@ { - "$schema": "../../sdk/typescript/schemas/project-config.schema.json", + "$schema": "./node_modules/@openai/codex-security/schemas/project-config.schema.json", "scan": { "mode": "standard", "scope": { "paths": ["sdk/typescript/src"] } diff --git a/docs/examples/codex-security.yaml b/docs/examples/codex-security.yaml index 31e53a7d4..e7b888818 100644 --- a/docs/examples/codex-security.yaml +++ b/docs/examples/codex-security.yaml @@ -1,4 +1,5 @@ -# yaml-language-server: $schema=../../sdk/typescript/schemas/project-config.schema.json +# yaml-language-server: $schema=./node_modules/@openai/codex-security/schemas/project-config.schema.json +# Schema path assumes this file is copied to the root of your project. scan: mode: standard scope: diff --git a/docs/project-configuration.md b/docs/project-configuration.md index 9a129ab74..2025b74f7 100644 --- a/docs/project-configuration.md +++ b/docs/project-configuration.md @@ -8,10 +8,31 @@ codex-security scan . -c codex-security.yaml --dry-run --json codex-security scan . -c codex-security.json --model gpt-5.6-terra ``` -The supported extensions are `.yaml`, `.yml`, and `.json`. Without `-c`, no file -is loaded, even if `codex-security.yaml` exists. Other commands and SDK `run()` -calls do not discover project files. The repository comes from the positional -argument or invocation directory; the file cannot select a different target. +The supported extensions are `.yaml`, `.yml`, and `.json`. `scan`, `bulk-scan`, +`scan-components`, and `info` accept `-c` / `--config`. An operator can set +`CODEX_SECURITY_PROJECT_CONFIG` instead; an explicit `-c` takes precedence. With +neither, no file is loaded, even if `codex-security.yaml` exists. SDK `run()` calls +and saved reruns do not read this environment variable or discover project files. +The repository comes from the command's target selection, not the config file. + +**Treat the selected file as trusted operator configuration, with the same +authority as CLI options and SDK `codexOverrides`.** Native Codex settings can +start configured MCP server processes and select model-service destinations. Do not select a +file controlled by an untrusted repository or pull request. In CI, keep the +scanner configuration in an operator-controlled location outside the checkout +being assessed. Explicit selection does not make a file safe to trust. + +Create a starter and inspect its settings without a repository or runtime: + +```sh +codex-security init +codex-security info -c codex-security.yaml --json +``` + +`init [file]` defaults to `codex-security.yaml`, refuses to overwrite an existing +file, and accepts `.yaml`, `.yml`, or `.json`. YAML starters show current defaults +as comments so future releases can still update defaults you have not overridden. +The editor hint expects the package to be installed locally in `node_modules`. For a project with a `src` directory: @@ -29,10 +50,12 @@ policy: All settings are optional; `{}` uses the existing defaults. No `version` field is needed. Unknown wrapper keys and invalid types are errors. Values are literal: -there is no executable configuration, environment interpolation, remote include, -or multiple-file merge. Wrapper `null` values do not reset settings. Project-file +the loader does not evaluate JavaScript, interpolate environment values, include remote files, +or merge multiple files. Wrapper `null` values do not reset settings. Project-file keys use `snake_case`, matching native Codex configuration. Keys inside `codex` keep their native spelling; names and values are not converted. +YAML anchors are supported; the parser retains its guard against excessive nested +alias expansion. The [YAML example](examples/codex-security.yaml) and equivalent [JSON example](examples/codex-security.json) select this repository's TypeScript @@ -126,11 +149,14 @@ const input = { const { config, options } = resolveProjectConfig(input, process.cwd()); ``` -Both return constructor `config` and scan `options`; the file loader also returns +Both return constructor `config`, scan `options`, and an immutable `sources` map; +the file loader also returns `projectConfig` path and source metadata. The optional directory argument defaults to the current directory. It locates a selected file or anchors an in-memory object's context, prompt, and output paths. Files anchor those paths to their own directory. Neither helper reads prompt contents or prepares a runtime. +Resolved context, prompt, and output paths have the SDK's `AbsolutePath` type; +scope paths remain relative to the selected repository. Use `security.preflight(repository, options)` for the same local checks as CLI `--dry-run`, including active-mode compatibility and remaining legacy deep @@ -147,7 +173,8 @@ SDK/CLI options and are not part of project files. set process status when the threshold is met; call `hasFindingsAtOrAbove()` and choose the caller's response. The method uses the same ordering as the CLI and does not filter findings. The CLI/file contract keeps its four reportable levels; -existing SDK calls also accept `informational`. +existing SDK calls also accept `informational`. An unknown threshold throws, +including when the result has no findings. ## Overrides and paths @@ -163,6 +190,10 @@ hints; parsing does not insert them. Lists are replaced, not concatenated. | CLI context, prompt, and output paths | Invocation directory | | Native values under `codex` | Existing native Codex rules | +Scope paths follow `scan --path`: the same config can select `src` in each target +repository. Context, instruction, validation, and output paths belong to the config +file and therefore stay anchored to its directory when the invocation moves. + For example, `--knowledge-base context.md` replaces the file's entire context list and resolves from the invocation directory. Existing regular-file, protected-path, credential, and outside-worktree output checks still apply. @@ -174,7 +205,8 @@ selectors fail. `--no-working-tree` disables a configured working-tree scope but does not clear a path or committed-diff scope. There is no general CLI reset for configured context, policy, cost limit, or scope. -Edit the file, select another file, or omit `-c`. An empty context list is valid. +Edit the file, select another file, or omit `-c` and unset +`CODEX_SECURITY_PROJECT_CONFIG`. An empty context list is valid. Native objects merge using the existing configuration code. Duplicate native CLI assignments remain errors; overriding a file value is valid. Selected native @@ -203,6 +235,9 @@ The cost limit is illustrative. Legacy user `[deep_scan]` TOML remains supported including `workers = "auto"`. File and CLI values override individual settings. All six effective values are resolved before runtime preparation and saved in new recipes. +If a runtime aliases the ambient TOML file, only explicit overrides are written +back; unrelated sections and inherited defaults remain untouched. Isolated runtime +files still receive a complete snapshot. A valid deep block can stay inactive in standard mode. Explicit deep CLI options still require deep mode. Deep diff scans and custom validation remain unsupported. @@ -214,8 +249,38 @@ scan attempt. In-flight work may exceed it. It is not a total budget for a batch follow-up actions. `fail_on_severity` changes the exit status without filtering the retained findings. +## Batch and component scans + +```sh +codex-security bulk-scan repositories.csv -c codex-security.yaml --output-dir ../batch-results +codex-security scan-components . --component src -c codex-security.yaml --output-dir ../component-results +``` + +`output.directory` can supply the batch output directory; `--output-dir` overrides +it. In the interactive bulk wizard, it supplies the proposed output directory. +Both commands use the same config precedence, path anchoring, native settings, +context, prompts, per-attempt cost limit, and severity policy as `scan`. + +A bulk CSV row's mode and scope override the file's defaults for that repository. +Deep settings apply only to deep rows. Component plans select each component's +scope, overriding `scan.scope`; `scan.mode` selects standard or deep component +scans. Batch `--workers` controls concurrent repositories or components, while +`scan.deep.workers` controls discovery workers within each deep scan. + +The severity policy returns exit `1` without discarding completed results or +retrying a scan just because it found issues. Errors or incomplete results take +precedence with exit `2`. Bulk resume retains the saved policy outcome and checks +that the selected scan configuration still matches the campaign. + ## Dry run and editor support +`info [-c FILE] --json` reports resolved settings and their sources without a scan +target, prompt-file reads, credentials checks, or runtime initialization. With no +file, it shows defaults. Active deep mode also resolves legacy TOML. It reports +effective model details and native key sources without dumping raw native values. +This is configuration inspection, +not a filesystem, target, or model-availability check. + `scan ... --dry-run --json` checks local inputs without starting Codex, verifying credentials, or establishing model availability. Removing `--dry-run` starts a scan and may incur model charges. Deep preflight shows all six effective settings @@ -229,11 +294,14 @@ effective model and effort. Raw native configuration and credentials are not dum Source paths use the project-file spelling, such as `scan.deep.stop_after_no_new`; existing output properties such as `deepScanSources` keep their names. +The source map includes all wrapper settings, including unset values attributed +to `default`; resolving preflight sources does not mutate previously returned maps. Help, version, and command schema output do not load project files. Missing, malformed, or invalid selected files exit `2`. Scan exit codes remain `0` for completion without a policy failure, `1` for the finding threshold, and `2` for -failed, invalid, incomplete, or interrupted scans. +failed, invalid, or incomplete scans. Signal cancellation retains `130` for +`SIGINT` and `143` for `SIGTERM`. The generated [project schema](../sdk/typescript/schemas/project-config.schema.json) is self-contained Draft-07 and ships in the npm package. A project-root YAML file @@ -261,6 +329,11 @@ file; older partial recipes continue using applicable defaults for missing value Recipes do not snapshot source or context contents. Reruns use the current checkout and current context files. Additional scan instructions are not retained: a new recipe records that requirement, and `scans rerun` refuses to omit them silently. -Start a new `scan -c FILE` or `scan --scan-prompt-file FILE` to supply them again. +Use `scans rerun [SCAN_ID] --scan-prompt-file FILE` to supply them again. A required +replacement must not be empty. The file resolves from the invocation directory. Custom validation keeps its existing `scans rerun --validation-prompt-file FILE` requirement. + +Workflow identity records explicitly requested deep settings, not ambient values +or shipped defaults, so changing those defaults does not prevent resumption. +Changing the explicit request still requires a different workflow ID. diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index a41d7d6b5..7efdff7f1 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -188,7 +188,8 @@ if ( `resolveProjectConfig(input, directory?)` accepts a typed `ProjectConfigInput` object with the same `snake_case` keys as YAML/JSON and returns the same `{ config, -options }` pair. `loadProjectConfig(file, directory?)` resolves the selected file +options }` pair and an immutable `sources` map. Resolved context, prompt, and output +paths have the `AbsolutePath` type. `loadProjectConfig(file, directory?)` resolves the selected file from `directory`, which defaults to the current directory; paths inside the file are relative to that file. Object paths are relative to the supplied directory. Scope paths remain relative to the selected repository. Neither helper starts a @@ -306,7 +307,8 @@ npx @openai/codex-security scan /path/to/repository --dry-run Use `scan --help` for options, `--version` for the installed version, and `info --json` for package, plugin, runtime, and model details. `--dry-run` -runs local preflight checks. +runs local preflight checks. `info -c FILE --json` inspects resolved configuration +and its sources without a repository or runtime. ### Project files @@ -315,12 +317,25 @@ Use `scan -c FILE` / `scan --config FILE` to load reusable scan settings: ```bash codex-security scan . -c codex-security.yaml --dry-run --json codex-security scan . -c codex-security.json --model gpt-5.6-terra +codex-security init +codex-security info -c codex-security.yaml --json ``` -Select one `.yaml`, `.yml`, or `.json` file. Omitting `-c` preserves existing scan -defaults without discovering files. The repository still comes from the positional -argument or invocation directory. Other commands and SDK `run()` calls do not -load project files automatically. +Select one `.yaml`, `.yml`, or `.json` file. `scan`, `bulk-scan`, `scan-components`, +and `info` accept `-c`. They also accept an operator-set +`CODEX_SECURITY_PROJECT_CONFIG`; an explicit `-c` wins. Without either, no file is +loaded or discovered. The repository still comes from the command's target +selection. SDK `run()` and saved reruns do not load project files automatically. + +The selected file is trusted like CLI options and SDK `codexOverrides`. Native +settings can start configured MCP server processes and select model-service destinations. Do not +select configuration controlled by an untrusted repository or pull request; keep +CI scanner configuration outside the checkout being assessed. + +`init [file]` writes `codex-security.yaml` by default and never overwrites an +existing file. YAML starters show defaults as comments; JSON starters contain the +editor schema hint. `info` reports effective model details and native key sources +without dumping raw native values. ```yaml # yaml-language-server: $schema=./node_modules/@openai/codex-security/schemas/project-config.schema.json @@ -350,8 +365,8 @@ and the behavior of `--provider openai` are unchanged. File context, instruction, validation, and output paths resolve from the file's directory. CLI file paths resolve from the invocation directory; scope paths resolve from the repository. The file cannot select a different repository or -enable automatic patching/publication. It does not support executable configs, -environment interpolation, remote includes, or multiple-file merging. +enable automatic patching/publication. The loader does not evaluate code, +interpolate environment values, include remote files, or merge multiple files. Dry-run output adds `projectConfig.path` and `projectConfig.sources`, selected prompt paths, and the finding policy without dumping raw native configuration. @@ -410,7 +425,7 @@ runtime or plugin compatibility. Older versions may omit the ID. ### Scan project components `scan --path` runs one scan across selected paths. To scan each local project -component separately in standard mode, use `scan-components`: +component separately (standard mode by default), use `scan-components`: ```bash npx @openai/codex-security scan-components /path/to/project \ @@ -418,6 +433,12 @@ npx @openai/codex-security scan-components /path/to/project \ --workers 4 --output-dir /path/outside/project/results ``` +Use `-c FILE` to share settings, including `scan.mode: deep`, context and prompt +files, per-scan deep workers, cost limits, and severity policy. Component plans +override the file's scope. `output.directory` supplies the results directory when +`--output-dir` is omitted. A configured severity threshold returns exit `1` after +completed scans; failures or incomplete results return `2`. + Use `--auto` instead of `--component` for a proposed split. Save a plan to review or edit, then run it with a new output directory: @@ -572,22 +593,23 @@ restrictions. ### Environment variables -| Variable | Effect | -| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | -| `OPENAI_API_KEY`, `CODEX_API_KEY` | Scan credentials; `OPENAI_API_KEY` wins if both are set. | -| `CODEX_SECURITY_LINEAR_TEAM`, `CODEX_SECURITY_LINEAR_PROJECT` | Default team and project for completed-scan publication. | -| `CODEX_SECURITY_LINEAR_API_KEY` | Personal API key for Linear patching and direct publication. | -| `CODEX_SECURITY_LOG_LEVEL` | CLI-only; `debug` enables verbose diagnostics. | -| `LOG_LEVEL` | CLI-only fallback when `CODEX_SECURITY_LOG_LEVEL` is unset. | -| `CODEX_SECURITY_STATE_DIR` | Private scan-history, workbench, and default artifact directory. | -| `CODEX_HOME` | Ambient Codex home for file-based sign-in and default state; defaults to `~/.codex`. | -| `CODEX_CLI_PATH` | Codex executable for authentication, plugin setup, scans, and workers. | -| `PYTHON` | Python interpreter when `--python` or SDK `pythonPath` is unset. | -| `GH_HOST` | GitHub Enterprise host for interactive `bulk-scan` discovery. | -| `CODEX_SECURITY_NO_UPDATE_NOTICE`, `NO_UPDATE_NOTIFIER` | Either variable disables interactive update notices. | -| `CODEX_SECURITY_NPM_REGISTRY`, `npm_config_registry`, `NPM_CONFIG_REGISTRY` | Update-check registry, in precedence order. | -| `CI` | Disables interactive update notices. | -| `NO_COLOR`, `TERM` | Disables colored scan history when `NO_COLOR` is defined or `TERM=dumb`. | +| Variable | Effect | +| --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| `OPENAI_API_KEY`, `CODEX_API_KEY` | Scan credentials; `OPENAI_API_KEY` wins if both are set. | +| `CODEX_SECURITY_LINEAR_TEAM`, `CODEX_SECURITY_LINEAR_PROJECT` | Default team and project for completed-scan publication. | +| `CODEX_SECURITY_LINEAR_API_KEY` | Personal API key for Linear patching and direct publication. | +| `CODEX_SECURITY_LOG_LEVEL` | CLI-only; `debug` enables verbose diagnostics. | +| `LOG_LEVEL` | CLI-only fallback when `CODEX_SECURITY_LOG_LEVEL` is unset. | +| `CODEX_SECURITY_STATE_DIR` | Private scan-history, workbench, and default artifact directory. | +| `CODEX_SECURITY_PROJECT_CONFIG` | Trusted project file for `scan`, `bulk-scan`, `scan-components`, and `info`; `-c` wins. Unset by default. | +| `CODEX_HOME` | Ambient Codex home for file-based sign-in and default state; defaults to `~/.codex`. | +| `CODEX_CLI_PATH` | Codex executable for authentication, plugin setup, scans, and workers. | +| `PYTHON` | Python interpreter when `--python` or SDK `pythonPath` is unset. | +| `GH_HOST` | GitHub Enterprise host for interactive `bulk-scan` discovery. | +| `CODEX_SECURITY_NO_UPDATE_NOTICE`, `NO_UPDATE_NOTIFIER` | Either variable disables interactive update notices. | +| `CODEX_SECURITY_NPM_REGISTRY`, `npm_config_registry`, `NPM_CONFIG_REGISTRY` | Update-check registry, in precedence order. | +| `CI` | Disables interactive update notices. | +| `NO_COLOR`, `TERM` | Disables colored scan history when `NO_COLOR` is defined or `TERM=dumb`. | Custom Codex executables need thread source attribution for `exec` and `app-server` (Codex 0.149.1+). On Windows, use a native `.exe` or `.com`; @@ -640,6 +662,11 @@ npx @openai/codex-security bulk-scan repositories.csv \ `--scan-prompt-file PATH` adds instructions to a scan or all bulk scans. Each repository's CSV `prompt` follows the shared instructions. +`-c FILE` shares config with single scans: CSV mode/scope override file defaults, +and deep settings apply only to deep rows. `output.directory` can supply the +results directory. `fail_on_severity` returns exit `1` without retrying completed +scans, including when resuming saved results. A changed project configuration +requires a new campaign output directory. `--post-scan-prompt-file PATH` runs a follow-up in the same authenticated session, even after a failed or incomplete scan, but not after cancellation or a cost-limit stop. @@ -866,8 +893,9 @@ fallback behavior. Context paths and the current checkout are not immutable inpu snapshots. Additional scan instructions are not saved. New recipes mark this requirement, -and `scans rerun` refuses to omit them silently; use a new `scan --scan-prompt-file` -or `scan -c` invocation to supply them again. Custom validation keeps its existing +and `scans rerun` refuses to omit them silently; use +`scans rerun [SCAN_ID] --scan-prompt-file FILE` to supply a nonempty replacement. +Replacement files resolve from the invocation directory. Custom validation keeps its existing `scans rerun --validation-prompt-file` requirement. | Command | Purpose | diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index e5e125cdd..f82d9e23b 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -61,7 +61,7 @@ "test:mcp": "node --run build:plugin && pnpm --dir ../../plugins/codex-security/mcp-app run test:mcp", "test:mutation": "stryker run", "test:package": "node scripts/smoke-package.mjs", - "types": "node scripts/generate-deep-defaults.mjs --check && pnpm run generate:models:check && pnpm --dir ../../plugins/codex-security/mcp-app run typecheck && tsc --noEmit" + "types": "node scripts/generate-deep-defaults.mjs --check && node scripts/generate-project-config-schema.mjs --check && pnpm run generate:models:check && pnpm --dir ../../plugins/codex-security/mcp-app run typecheck && tsc --noEmit" }, "dependencies": { "@inquirer/prompts": "8.3.0", diff --git a/sdk/typescript/scripts/check-package.mjs b/sdk/typescript/scripts/check-package.mjs index 7a2587782..9ab6e9689 100644 --- a/sdk/typescript/scripts/check-package.mjs +++ b/sdk/typescript/scripts/check-package.mjs @@ -175,6 +175,7 @@ const distFiles = new Set( "component-plan", "component-scan", "config", + "config-path", "contract", "cost", "cost-model", @@ -187,6 +188,7 @@ const distFiles = new Set( "project-config", "project-config-schema", "prompt-files", + "scan-modes", "scan-settings", "errors", "github", diff --git a/sdk/typescript/scripts/generate-project-config-schema.mjs b/sdk/typescript/scripts/generate-project-config-schema.mjs index e160407be..7a0e1a789 100644 --- a/sdk/typescript/scripts/generate-project-config-schema.mjs +++ b/sdk/typescript/scripts/generate-project-config-schema.mjs @@ -1,10 +1,34 @@ -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; import { format } from "prettier"; -import { projectConfigJsonSchema } from "../dist/project-config-schema.js"; -const directory = new URL("../schemas/", import.meta.url); -await mkdir(directory, { recursive: true }); -await writeFile( - new URL("project-config.schema.json", directory), - await format(JSON.stringify(projectConfigJsonSchema()), { parser: "json" }), +// Load the source schema without requiring a prior SDK build during typechecks. +const bundled = await build({ + entryPoints: [ + fileURLToPath(new URL("../src/project-config-schema.ts", import.meta.url)), + ], + bundle: true, + platform: "node", + format: "esm", + write: false, +}); +const { projectConfigJsonSchema } = await import( + `data:text/javascript;base64,${Buffer.from(bundled.outputFiles[0].contents).toString("base64")}` ); + +const directory = new URL("../schemas/", import.meta.url); +const output = new URL("project-config.schema.json", directory); +const contents = await format(JSON.stringify(projectConfigJsonSchema()), { + parser: "json", +}); +if (process.argv.includes("--check")) { + if ((await readFile(output, "utf8")).replaceAll("\r\n", "\n") !== contents) { + throw new Error( + "Project configuration schema is out of date. Run pnpm build.", + ); + } +} else { + await mkdir(directory, { recursive: true }); + await writeFile(output, contents); +} diff --git a/sdk/typescript/scripts/smoke-package.mjs b/sdk/typescript/scripts/smoke-package.mjs index 4c28ad7dd..ba63ad62e 100644 --- a/sdk/typescript/scripts/smoke-package.mjs +++ b/sdk/typescript/scripts/smoke-package.mjs @@ -409,7 +409,10 @@ try { assert.deepEqual(loaded.config, resolved.config); assert.deepEqual(loaded.options, resolved.options); assert.equal(loaded.options.subagents, 0); - assert.equal(loaded.options.failureSeverity, "high");`, + assert.equal(loaded.options.failureSeverity, "high"); + assert.equal(loaded.sources["scan.deep.subagents_per_worker"], "project"); + assert.equal(loaded.sources["output.directory"], "default"); + assert.equal(Object.isFrozen(loaded.sources), true);`, ], { cwd: consumer }, ); @@ -489,6 +492,27 @@ try { assert.match(help, /\bpublish\b/u); assert.match(help, /\bdedupe\b/u); + const starterPath = join(consumer, "codex-security.yaml"); + const starter = JSON.parse( + run(process.execPath, [launcher, "init", "--json"], { + cwd: consumer, + capture: true, + }), + ); + assert.equal(starter.path, starterPath); + for (const args of [["-c", starterPath], []]) { + const info = JSON.parse( + run(process.execPath, [launcher, "info", ...args, "--json"], { + cwd: consumer, + capture: true, + env: { ...process.env, CODEX_SECURITY_PROJECT_CONFIG: starterPath }, + }), + ); + assert.equal(info.configuration.path, starterPath); + assert.equal(info.configuration.settings.mode, "standard"); + assert.equal(info.configuration.sources["scan.mode"], "default"); + } + const publicationScan = join(consumer, "publication-scan"); await cp( join(installedRoot, "_bundled_plugin", "examples", "completed-scan"), diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 84bea56ed..71d569b02 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -463,16 +463,11 @@ export class CodexSecurity { this.#abortController.signal, ...(options.signal ? [options.signal] : []), ]); - const local = await this.#validateLocalInputs( + const local = await this.#prepareLocalInputs( repository, { ...options, outputDir: undefined, archiveExisting: false }, signal, ); - options = { - ...options, - ...local.prompts, - ...local.deepScanConfiguration?.settings, - }; const workflow = new FindingWorkflow( workflowId, this.#dependencies.environment, @@ -487,6 +482,7 @@ export class CodexSecurity { config: this.config, options: { ...options, + ...local.prompts, target: options.target ?? "repository", mode: options.mode ?? DEFAULT_SCAN_MODE, outputDir: @@ -537,7 +533,7 @@ export class CodexSecurity { } await workflow.begin("scan"); try { - const result = await this.#run(repository, options); + const result = await this.#run(repository, options, local); await workflow.protectArtifacts(result.scanDir); await workflow.bind({ scanId: result.manifest.scan.id, @@ -583,7 +579,7 @@ export class CodexSecurity { ); } const finding = jsonForPrompt(options.finding); - const inputs = await this.#validateLocalInputs( + const inputs = await this.#prepareLocalInputs( options.repositoryPath, options, signal, @@ -680,7 +676,7 @@ export class CodexSecurity { options: ScanOptions = {}, ): Promise { this.#requireOpen(); - const inputs = await this.#validateLocalInputs( + const inputs = await this.#prepareLocalInputs( repository, options, options.signal, @@ -739,7 +735,11 @@ export class CodexSecurity { }; } - async #run(repository: string, options: ScanOptions): Promise { + async #run( + repository: string, + options: ScanOptions, + preparedInputs?: LocalScanInputs, + ): Promise { this.#requireOpen(); const costAbortController = new AbortController(); const signal = AbortSignal.any([ @@ -747,6 +747,7 @@ export class CodexSecurity { costAbortController.signal, ...(options.signal === undefined ? [] : [options.signal]), ]); + let resolvedOptions = options; let scanDir = ""; let archivedScanDir: string | null = null; let targetPathsFile: string | null = null; @@ -780,7 +781,18 @@ export class CodexSecurity { throwIfAborted(signal, scanDir); }; - // Validate all local inputs before runtime initialization or plugin-Python discovery. + // Workflows reuse the prepared prompts and deep settings, but validate the + // output only when starting new work; a completed workflow may already own it. + const inputs = + preparedInputs === undefined + ? await this.#prepareLocalInputs(repository, options, signal) + : { + ...preparedInputs, + outputDir: await prepareScanOutputDir( + options, + preparedInputs.protectedRoot, + ), + }; const { repository: repo, target: normalized, @@ -790,14 +802,14 @@ export class CodexSecurity { stateDirectory, deepScanConfiguration, prompts, - } = await this.#validateLocalInputs(repository, options, signal); - options = { ...options, ...prompts }; + } = inputs; + resolvedOptions = { ...options, ...prompts }; checkOpen(); let temporaryRoot: string | undefined; if ( requestedOutput === null || this.#runtime === null || - options.knowledgeBasePaths?.length + resolvedOptions.knowledgeBasePaths?.length ) { temporaryRoot = await realpath(tmpdir()); requireOutputOutsideRepository( @@ -806,9 +818,9 @@ export class CodexSecurity { "temporary", ); } - if (options.knowledgeBasePaths?.length) { + if (resolvedOptions.knowledgeBasePaths?.length) { knowledgeBase = await prepareKnowledgeBase( - options.knowledgeBasePaths, + resolvedOptions.knowledgeBasePaths, signal, ); } @@ -816,7 +828,7 @@ export class CodexSecurity { const session = await this.#prepareSession( { protectedRoot }, - options, + resolvedOptions, signal, temporaryRoot, mode === "deep", @@ -865,13 +877,13 @@ export class CodexSecurity { basename(repo), scanOutputRoot, (path) => requireOutputOutsideRepository(protectedRoot, path), - options.archiveExisting, + resolvedOptions.archiveExisting, (archiveDir) => { archivedScanDir = archiveDir; notifyObserver( "onOutputArchived", - options.onOutputArchived, - options.onObserverError, + resolvedOptions.onOutputArchived, + resolvedOptions.onObserverError, archiveDir, ); }, @@ -880,8 +892,8 @@ export class CodexSecurity { requireModelSafeOutputDir(scanDir); notifyObserver( "onOutputDirReady", - options.onOutputDirReady, - options.onObserverError, + resolvedOptions.onOutputDirReady, + resolvedOptions.onObserverError, scanDir, ); checkOpen(); @@ -904,7 +916,7 @@ export class CodexSecurity { } const skillName = skillNameFor(normalized, mode); const discoveryPrompt = - options.validationPrompt === undefined + resolvedOptions.validationPrompt === undefined ? undefined : await customDiscoveryPrompt( runtime.plugin.installedRoot, @@ -937,8 +949,8 @@ export class CodexSecurity { pluginVersion: runtime.plugin.version, }; const { model } = scanModelConfiguration(effectiveConfig); - validateScanCostLimit(options.maxCostUsd, model); - if (mode === "deep" && options.maxCostUsd !== undefined) { + validateScanCostLimit(resolvedOptions.maxCostUsd, model); + if (mode === "deep" && resolvedOptions.maxCostUsd !== undefined) { budgetRecovery = { expectation, pluginRoot: runtime.plugin.installedRoot, @@ -959,20 +971,20 @@ export class CodexSecurity { reviewedFileCount = progress.filesCompleted; notifyObserver( "onProgress", - options.onProgress, - options.onObserverError, + resolvedOptions.onProgress, + resolvedOptions.onObserverError, { ...progress, filesTotal: scopeFileCount }, ); }; const reportTrackingError = (error: unknown): void => { - if (options.maxCostUsd !== undefined) { + if (resolvedOptions.maxCostUsd !== undefined) { costAbortController.abort(error); return; } notifyObserver( "onWarning", - options.onWarning, - options.onObserverError, + resolvedOptions.onWarning, + resolvedOptions.onObserverError, `Could not track scan activity: ${errorMessage(error)}`, ); }; @@ -981,46 +993,47 @@ export class CodexSecurity { model, repository: repo, scanDirectory: scanDir, - maxCostUsd: options.maxCostUsd, + maxCostUsd: resolvedOptions.maxCostUsd, onActivity: - options.onActivity === undefined + resolvedOptions.onActivity === undefined ? undefined : (activity) => notifyObserver( "onActivity", - options.onActivity, - options.onObserverError, + resolvedOptions.onActivity, + resolvedOptions.onObserverError, activity, ), onSessionEvent: - options.onSessionEvent === undefined + resolvedOptions.onSessionEvent === undefined ? undefined : (event) => notifyObserver( "onSessionEvent", - options.onSessionEvent, - options.onObserverError, + resolvedOptions.onSessionEvent, + resolvedOptions.onObserverError, event, ), onProgress: - options.onProgress === undefined ? undefined : reportProgress, + resolvedOptions.onProgress === undefined ? undefined : reportProgress, onCost: - options.onCost === undefined && options.maxCostUsd === undefined + resolvedOptions.onCost === undefined && + resolvedOptions.maxCostUsd === undefined ? undefined : (cost) => { notifyObserver( "onCost", - options.onCost, - options.onObserverError, + resolvedOptions.onCost, + resolvedOptions.onObserverError, cost, ); if ( - options.maxCostUsd !== undefined && - cost.estimatedUsd > options.maxCostUsd + resolvedOptions.maxCostUsd !== undefined && + cost.estimatedUsd > resolvedOptions.maxCostUsd ) { costAbortController.abort( new ScanCostLimitExceededError( - options.maxCostUsd, + resolvedOptions.maxCostUsd, cost, scanDir, ), @@ -1030,21 +1043,22 @@ export class CodexSecurity { onError: reportTrackingError, }); costTracker = tracker; - const recipe = scanRecipe( - repo, - normalized, + const recipe = scanRecipe({ + repository: repo, + target: normalized, mode, - expectation.repositoryRevision, - runtime.plugin.version, - { ...preflightConfig, approval_policy: approvalPolicy }, - options.failureSeverity, - knowledgeBase?.sources, - options.maxCostUsd, - deepScanConfiguration?.settings, - options.auth, - ); - if (options.scanPrompt?.trim()) recipe["requiresScanPrompt"] = true; - if (options.validationPrompt !== undefined) + repositoryRevision: expectation.repositoryRevision, + pluginVersion: runtime.plugin.version, + config: { ...preflightConfig, approval_policy: approvalPolicy }, + failOnSeverity: resolvedOptions.failureSeverity, + knowledgeBasePaths: knowledgeBase?.sources, + maxCostUsd: resolvedOptions.maxCostUsd, + deepScan: deepScanConfiguration?.settings, + auth: resolvedOptions.auth, + }); + if (resolvedOptions.scanPrompt?.trim()) + recipe["requiresScanPrompt"] = true; + if (resolvedOptions.validationPrompt !== undefined) recipe["validationMode"] = "custom"; const workbenchOptions: WorkbenchCommandOptions = { python, @@ -1052,7 +1066,7 @@ export class CodexSecurity { environment: { ...selectedScanEnvironment( runtime.environment, - options.auth, + resolvedOptions.auth, modelProvider, ), CODEX_SECURITY_STATE_DIR: stateDirectory, @@ -1069,20 +1083,22 @@ export class CodexSecurity { "--scan-dir", scanDir, "--registration-json-stdin", - ...(options.archiveExisting === true ? ["--archive-existing"] : []), + ...(resolvedOptions.archiveExisting === true + ? ["--archive-existing"] + : []), ...(archivedScanDir === null ? [] : ["--archived-scan-dir", archivedScanDir]), - ...(options.parentScanId === undefined + ...(resolvedOptions.parentScanId === undefined ? [] - : ["--parent-scan-id", options.parentScanId]), + : ["--parent-scan-id", resolvedOptions.parentScanId]), ], JSON.stringify({ recipe, - userContext: options.scanPrompt, - ...(options.workflowId === undefined + userContext: resolvedOptions.scanPrompt, + ...(resolvedOptions.workflowId === undefined ? {} - : { workflowId: options.workflowId }), + : { workflowId: resolvedOptions.workflowId }), }), ); const scanId = registration["scanId"]; @@ -1142,8 +1158,8 @@ export class CodexSecurity { tracker.setExpectedFilesTotal(scopeFileCount); notifyObserver( "onProgress", - options.onProgress, - options.onObserverError, + resolvedOptions.onProgress, + resolvedOptions.onObserverError, { phase: "preflight", filesCompleted: 0, @@ -1152,7 +1168,7 @@ export class CodexSecurity { ); } activeScan = { id: scanId, options: workbenchOptions }; - if (mode === "deep" && options.onDeepProgress !== undefined) { + if (mode === "deep" && resolvedOptions.onDeepProgress !== undefined) { let progressWarningReported = false; deepProgressTracker = new DeepScanProgressTracker({ read: (progressSignal) => @@ -1166,8 +1182,8 @@ export class CodexSecurity { onProgress: (progress) => notifyObserver( "onDeepProgress", - options.onDeepProgress, - options.onObserverError, + resolvedOptions.onDeepProgress, + resolvedOptions.onObserverError, progress, ), onError: (error) => { @@ -1175,15 +1191,15 @@ export class CodexSecurity { progressWarningReported = true; notifyObserver( "onWarning", - options.onWarning, - options.onObserverError, + resolvedOptions.onWarning, + resolvedOptions.onObserverError, `Could not track Deep Scan progress: ${errorMessage(error)}`, ); }, }); deepProgressTracker.start(); } - if (options.validationPrompt !== undefined) { + if (resolvedOptions.validationPrompt !== undefined) { await writeCustomValidationStatus( scanDir, { scanId, status: "pending" }, @@ -1198,8 +1214,8 @@ export class CodexSecurity { scanId, runtime.configPath !== undefined, knowledgeBase !== null, - options.scanPrompt, - options.maxCostUsd !== undefined, + resolvedOptions.scanPrompt, + resolvedOptions.maxCostUsd !== undefined, discoveryPrompt, ); checkOpen(); @@ -1296,7 +1312,7 @@ export class CodexSecurity { const { codex, environment } = this.#createSessionCodex( session, runtimePaths, - options.auth, + resolvedOptions.auth, ); const thread = codex.startThread({ threadSource: CODEX_SECURITY_THREAD_SOURCES.scan, @@ -1316,7 +1332,7 @@ export class CodexSecurity { await chmod(targetPathsFile, 0o400); } checkOpen(); - const postScanPrompt = options.postScanPrompt; + const postScanPrompt = resolvedOptions.postScanPrompt; if (postScanPrompt?.trim()) { runPostScan = () => thread.runStreamed(postScanPrompt, { signal }); } @@ -1349,21 +1365,21 @@ export class CodexSecurity { } catch (error) { notifyObserver( "onWarning", - options.onWarning, - options.onObserverError, + resolvedOptions.onWarning, + resolvedOptions.onObserverError, `Could not save scan session: ${safeErrorMessage(error)}`, ); } }, onFinalize: async (usage) => { - if (options.validationPrompt !== undefined) { + if (resolvedOptions.validationPrompt !== undefined) { await runCustomValidation({ repository: repo, target: normalized, scanDir, scanId, pluginRoot: runtime.plugin.installedRoot, - prompt: options.validationPrompt, + prompt: resolvedOptions.validationPrompt, falsePositives: falsePositiveExamples, signal, run: async (validationPrompt, outputSchema) => { @@ -1390,8 +1406,8 @@ export class CodexSecurity { onReconnect: (message, attempts) => notifyObserver( "onReconnect", - options.onReconnect, - options.onObserverError, + resolvedOptions.onReconnect, + resolvedOptions.onObserverError, ...attempts, reconnectDetails(message), ), @@ -1409,16 +1425,19 @@ export class CodexSecurity { customValidationComplete = true; } const snapshot = await tracker.stop(usage).catch((error: unknown) => { - if (options.maxCostUsd !== undefined) throw error; + if (resolvedOptions.maxCostUsd !== undefined) throw error; reportTrackingError(error); return { usage, cost: estimateScanCost(model, usage) }; }); throwIfAborted(signal, scanDir); - if (options.maxCostUsd !== undefined && snapshot.cost === null) { + if ( + resolvedOptions.maxCostUsd !== undefined && + snapshot.cost === null + ) { notifyObserver( "onWarning", - options.onWarning, - options.onObserverError, + resolvedOptions.onWarning, + resolvedOptions.onObserverError, "Scan completed, but its cost limit could not be verified because model pricing or token usage is unavailable.", ); } @@ -1460,10 +1479,10 @@ export class CodexSecurity { : []; return snapshot.usage; }, - onScanStarted: options.onScanStarted, - onTrustedAccessStatus: options.onTrustedAccessStatus, - onReconnect: options.onReconnect, - onActivity: options.onActivity, + onScanStarted: resolvedOptions.onScanStarted, + onTrustedAccessStatus: resolvedOptions.onTrustedAccessStatus, + onReconnect: resolvedOptions.onReconnect, + onActivity: resolvedOptions.onActivity, onProgress: (progress) => { if ( progress.phase === "discovery" && @@ -1476,9 +1495,9 @@ export class CodexSecurity { } reportProgress(progress); }, - onWorkerStatus: options.onWorkerStatus, - onWarning: options.onWarning, - onObserverError: options.onObserverError, + onWorkerStatus: resolvedOptions.onWorkerStatus, + onWarning: resolvedOptions.onWarning, + onObserverError: resolvedOptions.onObserverError, }); checkOpen(); const completion = await workbench(workbenchOptions, [ @@ -1504,8 +1523,8 @@ export class CodexSecurity { if (typeof warning === "string") { notifyObserver( "onWarning", - options.onWarning, - options.onObserverError, + resolvedOptions.onWarning, + resolvedOptions.onObserverError, warning, targetWarnings.has(warning) ? { kind: "target_changed" } @@ -1547,9 +1566,9 @@ export class CodexSecurity { pluginRoot: runtime.plugin.installedRoot, expectation, model, - onReconnect: options.onReconnect, - onWorkerStatus: options.onWorkerStatus, - onObserverError: options.onObserverError, + onReconnect: resolvedOptions.onReconnect, + onWorkerStatus: resolvedOptions.onWorkerStatus, + onObserverError: resolvedOptions.onObserverError, }); checkOpen(); } catch (error) { @@ -1581,8 +1600,8 @@ export class CodexSecurity { ); notifyObserver( "onWarning", - options.onWarning, - options.onObserverError, + resolvedOptions.onWarning, + resolvedOptions.onObserverError, `Could not run post-scan instructions: ${errorMessage(error)}`, ); } @@ -1625,8 +1644,8 @@ export class CodexSecurity { } catch (error) { notifyObserver( "onWarning", - options.onWarning, - options.onObserverError, + resolvedOptions.onWarning, + resolvedOptions.onObserverError, `Could not update repository findings: ${errorMessage(error)}`, ); } @@ -1646,7 +1665,7 @@ export class CodexSecurity { budgetRecovery.threadId !== null && activeScan !== null && !this.#abortController.signal.aborted && - options.signal?.aborted !== true + resolvedOptions.signal?.aborted !== true ) { try { const completion = await workbench( @@ -1675,7 +1694,9 @@ export class CodexSecurity { budgetRecovery.expectation, AbortSignal.any([ this.#abortController.signal, - ...(options.signal === undefined ? [] : [options.signal]), + ...(resolvedOptions.signal === undefined + ? [] + : [resolvedOptions.signal]), ]), true, ); @@ -1703,8 +1724,8 @@ export class CodexSecurity { : [failure.message]) { notifyObserver( "onWarning", - options.onWarning, - options.onObserverError, + resolvedOptions.onWarning, + resolvedOptions.onObserverError, warning, targetWarnings.has(warning) ? { kind: "target_changed" } @@ -1717,7 +1738,7 @@ export class CodexSecurity { } if (activeScan !== null) { if ( - options.validationPrompt !== undefined && + resolvedOptions.validationPrompt !== undefined && !customValidationComplete ) { await writeCustomValidationStatus(scanDir, { @@ -1750,8 +1771,8 @@ export class CodexSecurity { } catch (postScanError) { notifyObserver( "onWarning", - options.onWarning, - options.onObserverError, + resolvedOptions.onWarning, + resolvedOptions.onObserverError, `Could not run post-scan instructions: ${errorMessage(postScanError)}`, ); } @@ -1774,11 +1795,11 @@ export class CodexSecurity { removeTargetPathsFile(targetPathsFile), ])) { if (cleanup.status === "rejected") { - warnCleanupFailed(options, cleanup.reason); + warnCleanupFailed(resolvedOptions, cleanup.reason); } } } catch (error) { - warnCleanupFailed(options, error); + warnCleanupFailed(resolvedOptions, error); } finally { // Release any remaining startup lock, but preserve the scan's error if both // the scan and lock cleanup fail. @@ -1786,7 +1807,7 @@ export class CodexSecurity { await releaseCredentialHome?.(); } catch (error) { if (!scanFailure) throw error; - warnCleanupFailed(options, error); + warnCleanupFailed(resolvedOptions, error); } } } @@ -2335,12 +2356,12 @@ export class CodexSecurity { runtime.effectiveConfig = mergedConfig; } - async #validateLocalInputs( + async #prepareLocalInputs( repository: string, options: ScanOptions, signal?: AbortSignal, ): Promise { - deepScanOptions(options); + const deep = deepScanOptions(options); const identifier = options.safetyIdentifier; if ( identifier !== undefined && @@ -2389,13 +2410,7 @@ export class CodexSecurity { throwIfAborted(signal); const protectedRoot = (await enclosingGitWorktreeRoot(repo, signal)) ?? repo; - const requestedOutput = await validateOutputDir( - options.outputDir, - options.archiveExisting, - ); - if (requestedOutput !== null) { - requireOutputOutsideRepository(protectedRoot, requestedOutput); - } + const requestedOutput = await prepareScanOutputDir(options, protectedRoot); const stateDirectory = codexSecurityStateDirectory( this.#dependencies.environment, ); @@ -2426,7 +2441,7 @@ export class CodexSecurity { ...(mode === "deep" ? { deepScanConfiguration: await resolveDeepScanConfig( - options, + deep, join( expandHome( environmentValue( @@ -3078,19 +3093,31 @@ function targetInstruction(target: NormalizedTarget, python: string): string { return `Scan target: staged and unstaged working-tree changes against ${target.base}.`; } -function scanRecipe( - repository: string, - target: NormalizedTarget, - mode: ScanMode, - repositoryRevision: string | null, - pluginVersion: string, - preflightConfig: JsonObject, - failOnSeverity?: SeverityLevel, - knowledgeBasePaths?: string[], - maxCostUsd?: number, - deepScan?: Required, - auth?: ScanAuthMode, -): JsonObject { +function scanRecipe({ + repository, + target, + mode, + repositoryRevision, + pluginVersion, + config, + failOnSeverity, + knowledgeBasePaths, + maxCostUsd, + deepScan, + auth, +}: { + repository: string; + target: NormalizedTarget; + mode: ScanMode; + repositoryRevision: string | null; + pluginVersion: string; + config: JsonObject; + failOnSeverity?: SeverityLevel; + knowledgeBasePaths?: string[]; + maxCostUsd?: number; + deepScan?: Required; + auth?: ScanAuthMode; +}): JsonObject { return { repository, target: { @@ -3104,7 +3131,7 @@ function scanRecipe( mode, ...(repositoryRevision === null ? {} : { repositoryRevision }), pluginVersion, - config: preflightConfig, + config, ...(auth === undefined ? {} : { auth }), ...(failOnSeverity === undefined ? {} : { failOnSeverity }), ...(knowledgeBasePaths === undefined ? {} : { knowledgeBasePaths }), @@ -3115,6 +3142,18 @@ function scanRecipe( }; } +async function prepareScanOutputDir( + options: Pick, + protectedRoot: string, +): Promise { + const output = await validateOutputDir( + options.outputDir, + options.archiveExisting, + ); + if (output !== null) requireOutputOutsideRepository(protectedRoot, output); + return output; +} + function validateScanCostLimit( maxCostUsd: number | undefined, model: string, diff --git a/sdk/typescript/src/bulk-scan-discovery.ts b/sdk/typescript/src/bulk-scan-discovery.ts index ef2511df2..3558bccf6 100644 --- a/sdk/typescript/src/bulk-scan-discovery.ts +++ b/sdk/typescript/src/bulk-scan-discovery.ts @@ -120,6 +120,7 @@ export function createBulkScanDiscoveryDependencies(options: { export async function runBulkScanWizard( dependencies: BulkScanDiscoveryDependencies, signal?: AbortSignal, + defaultOutputDir = "./security-scans", ): Promise { const { prompt } = dependencies; if (!prompt.isInteractive()) { @@ -153,7 +154,7 @@ export async function runBulkScanWizard( expandHome( await prompt.input( "Where should scan results be saved?", - "./security-scans", + defaultOutputDir, ), ), ); diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 7938d272c..346c570dd 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -25,7 +25,7 @@ import { rm, writeFile, } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { basename, dirname, @@ -188,9 +188,16 @@ import { readProjectConfig, resolveScanSettings, projectScopeTarget, + configurationSources, + projectConfigStarter, + type ConfigurationSource, + type ScopeProvenanceKey, type ProjectConfigProvenance, } from "./project-config.js"; import type { ProjectScope } from "./project-config-schema.js"; +import { resolveConfigPath, type AbsolutePath } from "./config-path.js"; +import { resolveDeepScanConfig } from "./deep-config.js"; +import { SCAN_MODES } from "./scan-modes.js"; import { DEEP_SCAN_SETTINGS, DeepScanSettingsSchema, @@ -199,7 +206,7 @@ import { REPORTABLE_SEVERITIES, SCAN_SEVERITIES, ScanSettingsSchema, - scanSettings, + pickScanSettings, meetsSeverity, type FailureSeverity, type ResolvedScanSettings, @@ -246,6 +253,11 @@ const PLUGIN_PATH_DESCRIPTION = "Codex Security plugin directory or ZIP (default: bundled plugin)."; const PYTHON_PATH_DESCRIPTION = "Python interpreter (default: PYTHON or automatic discovery)."; +const PROJECT_CONFIG_OPTION = optionValue("--config") + .optional() + .describe( + "Load a trusted YAML/JSON file (default: CODEX_SECURITY_PROJECT_CONFIG, otherwise no file).", + ); const EXPORT_DEFAULT_OUTPUTS = { csv: "findings.csv", json: "findings.json", @@ -863,16 +875,19 @@ function effortOption() { ); } -const DEEP_SCAN_OPTION_SCHEMAS = { - workers: DeepScanSettingsSchema.shape.workers, - subagents: DeepScanSettingsSchema.shape.subagents, - stopAfterNoNew: DeepScanSettingsSchema.shape.stopAfterNoNew, - maxDiscoveryRuns: DeepScanSettingsSchema.shape.maxDiscoveryRuns, - maxTimeHours: DeepScanSettingsSchema.shape.maxTimeHours, -}; - -export function resolveCliPath(directory: string, value: string): string { - return resolve(directory, expandHome(value)); +type DeepCliOptionName = Extract< + (typeof DEEP_SCAN_SETTINGS)[number], + readonly [string, string, string, string] +>[0]; +const DEEP_SCAN_OPTION_SCHEMAS = Object.fromEntries( + DEEP_SCAN_SETTINGS.filter(([, , , flag]) => flag !== null).map(([name]) => [ + name, + DeepScanSettingsSchema.shape[name], + ]), +) as Pick; + +export function resolveCliPath(directory: string, value: string): AbsolutePath { + return resolveConfigPath(directory, value); } interface ScanArguments extends ResolvedScanSettings { @@ -1814,6 +1829,11 @@ export async function main( .describe("Saved scan identifier (default: latest completed scan)."), }), options: z.object({ + scanPromptFile: optionValue("--scan-prompt-file") + .optional() + .describe( + "Supply additional scan instructions; required when the saved scan used them.", + ), validationPromptFile: optionValue("--validation-prompt-file") .optional() .describe( @@ -1835,10 +1855,26 @@ export async function main( "--scan-id", scanId, ]); - scanArguments = scanArgumentsFromRecipe( + scanArguments = await prepareScanArgumentsFromRecipe( recipe, scanId, - options.validationPromptFile, + { + scanPromptFile: + options.scanPromptFile === undefined + ? undefined + : resolveCliPath( + dependencies.currentDirectory(), + options.scanPromptFile, + ), + validationPromptFile: + options.validationPromptFile === undefined + ? undefined + : resolveCliPath( + dependencies.currentDirectory(), + options.validationPromptFile, + ), + }, + dependencies.currentDirectory(), ); scanArguments.verbose = options.verbose; } catch (error) { @@ -2690,11 +2726,7 @@ export async function main( }), options: z .object({ - config: optionValue("--config") - .optional() - .describe( - "Load one YAML or JSON project file. No file is loaded by default.", - ), + config: PROJECT_CONFIG_OPTION, workflowId: optionValue("--workflow-id") .optional() .describe( @@ -2807,17 +2839,6 @@ export async function main( .default(false) .describe("Validate local scan inputs without starting a scan."), }) - .refine( - (options) => - Number((options.path?.length ?? 0) > 0) + - Number(options.diff !== undefined) + - Number(options.workingTree === true) <= - 1, - { - message: - "--path, --diff, and --working-tree are mutually exclusive.", - }, - ) .refine( (options) => options.patchSeverity === undefined || options.patch, { @@ -2864,10 +2885,10 @@ export async function main( let outcome: ScanOutcome; try { const directory = dependencies.currentDirectory(); - const project = - options.config === undefined - ? undefined - : await readProjectConfig(options.config, directory); + const project = await selectedProjectConfig( + options.config, + dependencies, + ); const scope = resolveCliScope(project?.input.scan?.scope, { paths: options.path, diff: options.diff, @@ -2887,6 +2908,7 @@ export async function main( knowledgeBasePaths: options.knowledgeBase, scanPromptFile: options.scanPromptFile, validationPromptFile: options.validationPromptFile, + postScanPromptFile: options.postScanPromptFile, mode: options.mode, workers: options.workers, subagents: options.subagents, @@ -2905,9 +2927,8 @@ export async function main( ), }, directory, + scope.sources, ); - if (provenance !== undefined) - Object.assign(provenance.sources, scope.sources); if (options.archiveExisting && settings.outputDir === undefined) { throw new CodexSecurityError( "--archive-existing requires --output-dir.", @@ -2922,7 +2943,6 @@ export async function main( safetyIdentifier: options.safetyIdentifier, verbose: options.verbose, repository: args.repository, - postScanPromptFile: options.postScanPromptFile, archiveExisting: options.archiveExisting, pluginPath: options.pluginPath, pythonPath: options.python, @@ -3127,6 +3147,7 @@ export async function main( "Run standard scans for project components and combine the results.", destructive: true, mcp: false, + alias: { config: "c" }, args: z.object({ repository: z .string() @@ -3136,12 +3157,10 @@ export async function main( }), options: z .object({ - auth: z - .enum(SCAN_AUTH_MODES) - .default("auto") - .describe( - "Select ChatGPT, OPENAI_API_KEY/CODEX_API_KEY, or automatic authentication.", - ), + config: PROJECT_CONFIG_OPTION, + auth: ScanSettingsSchema.shape.auth.describe( + "Select ChatGPT, OPENAI_API_KEY/CODEX_API_KEY, or automatic authentication.", + ), component: z .array(optionValue("--component")) .default([]) @@ -3165,18 +3184,21 @@ export async function main( .describe( "Print status lines instead of the interactive dashboard.", ), - outputDir: optionValue("--output-dir").describe( - "Empty results directory outside the repository.", - ), + outputDir: optionValue("--output-dir") + .optional() + .describe("Empty results directory outside the repository."), workers: z .number() .int() .positive() .default(4) - .describe("Concurrent standard component scans."), + .describe( + "Concurrent component scans; deep workers are configured per scan.", + ), knowledgeBase: z .array(optionValue("--knowledge-base")) - .default([]) + .optional() + .meta({ default: [] }) .describe("Read shared security docs for every component."), scanPromptFile: optionValue("--scan-prompt-file") .optional() @@ -3240,15 +3262,38 @@ export async function main( try { const directory = dependencies.currentDirectory(); const repository = resolveCliPath(directory, args.repository ?? "."); + const project = await selectedProjectConfig( + options.config, + dependencies, + ); + const resolved = resolveScanSettings( + project, + { + auth: options.auth, + outputDir: options.outputDir, + knowledgeBasePaths: options.knowledgeBase, + scanPromptFile: options.scanPromptFile, + postScanPromptFile: options.postScanPromptFile, + maxCostUsd: options.maxCost, + codexOverrides: parseCodexOverrides( + options.codex, + options.model, + options.effort, + options.provider, + project?.input.codex, + ), + }, + directory, + ); + const settings = resolved.options; + if (settings.outputDir === undefined) + throw new ConfigurationError( + "--output-dir or output.directory is required for component scans.", + ); const config: CodexSecurityConfig = { + ...resolved.config, pluginPath: options.pluginPath, pythonPath: options.python, - codexOverrides: parseCodexOverrides( - options.codex, - options.model, - options.effort, - options.provider, - ), }; const components = options.componentsFile === undefined @@ -3272,7 +3317,8 @@ export async function main( repository, presentation: "components", model: scanModelConfiguration(await mergedCodexConfig(config)), - maxCostUsd: options.maxCost, + mode: settings.mode, + maxCostUsd: settings.maxCostUsd, clock: dependencies, color: dependencies.environment["NO_COLOR"] === undefined, sanitize: safeErrorMessage, @@ -3293,27 +3339,14 @@ export async function main( } const result = await runComponentScans({ repository, - outputDir: resolveCliPath(directory, options.outputDir), + outputDir: settings.outputDir, ...(options.auto ? { auto: true } : { components }), planOnly: options.planOnly, workers: options.workers, config, scanOptions: { - auth: options.auth, - knowledgeBasePaths: options.knowledgeBase.map((path) => - resolveCliPath(directory, path), - ), - ...(await resolveScanPrompts( - { - scanPromptFile: options.scanPromptFile, - postScanPromptFile: options.postScanPromptFile, - }, - repository, - directory, - )), - ...(options.maxCost === undefined - ? {} - : { maxCostUsd: options.maxCost }), + ...settings, + ...(await resolveScanPrompts(settings, repository, directory)), }, createSecurity: dependencies.createSecurity, planComponents: dependencies.planComponents, @@ -3358,7 +3391,9 @@ export async function main( result.incomplete || result.deduplication?.status === "incomplete" ? 2 - : 0); + : result.policyFailed + ? 1 + : 0); return { ...result }; } catch (error) { stopDashboard(); @@ -3376,6 +3411,7 @@ export async function main( "Discover repositories and run resumable bulk security scans.", destructive: true, mcp: false, + alias: { config: "c" }, args: z.object({ input: z .string() @@ -3386,6 +3422,7 @@ export async function main( ), }), options: z.object({ + config: PROJECT_CONFIG_OPTION, outputDir: z .string() .min(1, "--output-dir must not be empty.") @@ -3395,7 +3432,8 @@ export async function main( ), knowledgeBase: z .array(optionValue("--knowledge-base")) - .default([]) + .optional() + .meta({ default: [] }) .describe("Read shared security docs for every repository."), workers: z .number() @@ -3405,10 +3443,9 @@ export async function main( .describe( "Concurrent repository scans. Per-scan Codex workers are separate.", ), - mode: z - .enum(["standard", "deep"]) - .default("standard") - .describe("Default scan mode for repositories without a CSV mode."), + mode: ScanSettingsSchema.shape.mode.describe( + "Default scan mode for repositories without a CSV mode.", + ), scanPromptFile: optionValue("--scan-prompt-file") .optional() .describe("Append instructions from FILE to every scan."), @@ -3477,15 +3514,48 @@ export async function main( dependencies.addSignalListener("SIGTERM", onTerminate); try { const currentDirectory = dependencies.currentDirectory(); + const project = await selectedProjectConfig( + options.config, + dependencies, + ); + const overrides = { + mode: options.mode, + outputDir: options.outputDir, + knowledgeBasePaths: options.knowledgeBase, + scanPromptFile: options.scanPromptFile, + validationPromptFile: options.validationPromptFile, + postScanPromptFile: options.postScanPromptFile, + maxCostUsd: options.maxCost, + }; + const resolved = resolveScanSettings( + project, + overrides, + currentDirectory, + ); + const settings = resolved.options; const prompts = await resolveScanPrompts( - { - scanPromptFile: options.scanPromptFile, - postScanPromptFile: options.postScanPromptFile, - validationPromptFile: options.validationPromptFile, - }, + settings, currentDirectory, currentDirectory, ); + // A CSV row may choose a different mode; resolve the same file for both + // modes so inactive deep defaults remain available to deep rows. + const scanOptionsByMode = + project === undefined + ? undefined + : Object.fromEntries( + SCAN_MODES.map((mode) => [ + mode, + { + ...resolveScanSettings( + project, + { ...overrides, mode }, + currentDirectory, + ).options, + ...prompts, + }, + ]), + ); let inputPath: string; let outputDir: string; let githubHost: string | undefined; @@ -3503,41 +3573,47 @@ export async function main( currentDirectory: dependencies.currentDirectory, }), controller.signal, + settings.outputDir, ); if (wizard === null) return; inputPath = wizard.inputPath; outputDir = wizard.outputDir; githubHost = wizard.githubHost; } else { - if (options.outputDir === undefined) { + if (settings.outputDir === undefined) { throw new Error( "--output-dir is required with a repository CSV.", ); } inputPath = resolveCliPath(currentDirectory, args.input); - outputDir = resolveCliPath(currentDirectory, options.outputDir); + outputDir = settings.outputDir; } const result = await runMultiscan({ inputPath, outputDir, ...(githubHost === undefined ? {} : { githubHost }), workers: options.workers, - mode: options.mode, + mode: settings.mode, maxAttempts: options.maxAttempts, - ...(options.maxCost === undefined + ...(settings.maxCostUsd === undefined ? {} - : { maxCostUsd: options.maxCost }), - knowledgeBasePaths: options.knowledgeBase, + : { maxCostUsd: settings.maxCostUsd }), + knowledgeBasePaths: settings.knowledgeBasePaths, + scanOptionsByMode, ...prompts, config: { + codexOverrides: mergeCodexOverrides( + resolved.config.codexOverrides, + parseCodexOverrides( + options.codex, + options.model, + options.effort, + options.provider, + project?.input.codex, + ), + ), pluginPath: options.pluginPath, pythonPath: options.python, - codexOverrides: parseCodexOverrides( - options.codex, - options.model, - options.effort, - options.provider, - ), }, createSecurity: dependencies.createSecurity, signal: controller.signal, @@ -3550,7 +3626,11 @@ export async function main( }); exitCode = interruptedExitCode() ?? - (result.failed > 0 || result.incomplete > 0 ? 2 : 0); + (result.failed > 0 || result.incomplete > 0 + ? 2 + : result.policyFailed + ? 1 + : 0); return { ...result }; } catch (error) { exitCode = @@ -4301,8 +4381,41 @@ export async function main( } }, }) + .command("init", { + description: + "Write a starter project configuration without overwriting an existing file.", + destructive: true, + mcp: false, + args: z.object({ + file: z + .string() + .min(1) + .optional() + .describe("YAML or JSON destination (default: codex-security.yaml)."), + }), + output: z.object({ path: z.string() }).optional(), + async run({ args }) { + try { + const path = resolveCliPath( + dependencies.currentDirectory(), + args.file ?? "codex-security.yaml", + ); + await writeFile(path, projectConfigStarter(path), { + flag: "wx", + mode: 0o600, + }); + return { path }; + } catch (error) { + exitCode = 2; + errorOutput.write(`codex-security: ${errorMessage(error)}\n`); + } + }, + }) .command("info", { - description: "Show read-only SDK and bundled-plugin metadata.", + description: + "Show SDK metadata and resolved configuration without preparing a scan.", + alias: { config: "c" }, + options: z.object({ config: PROJECT_CONFIG_OPTION }), mcp: { annotations: { readOnlyHint: true, @@ -4322,8 +4435,31 @@ export async function main( model: z.string(), reasoningEffort: z.string(), nextStep: z.string(), + configuration: z.record(z.string(), z.unknown()), }), - run() { + async run({ options }) { + const directory = dependencies.currentDirectory(); + const project = await selectedProjectConfig( + options.config, + dependencies, + ); + const resolved = resolveScanSettings(project, {}, directory); + const codex = await mergedCodexConfig(resolved.config); + const deep = + resolved.options.mode === "deep" + ? await resolveDeepScanConfig( + resolved.options, + join( + expandHome( + environmentValue(dependencies.environment, "CODEX_HOME") ?? + join(homedir(), ".codex"), + dependencies.environment, + ), + "codex-security", + "config.toml", + ), + ) + : undefined; return { sdkVersion: VERSION, bundledPluginVersion: BUNDLED_PLUGIN_VERSION, @@ -4333,8 +4469,13 @@ export async function main( cliVersion: VERSION, codexVersion: CODEX_EXECUTABLE_VERSION, codexSdkVersion: CODEX_SDK_VERSION, - ...scanModelConfiguration(DEFAULT_CODEX_CONFIG), + ...scanModelConfiguration(codex), nextStep: "codex-security scan . --dry-run", + configuration: { + ...(project?.path === undefined ? {} : { path: project.path }), + settings: { ...resolved.options, ...deep?.settings }, + sources: configurationSources(resolved.sources, deep?.sources), + }, }; }, }); @@ -4405,19 +4546,23 @@ function defaultListCommand(argv: readonly string[]): readonly string[] { ]; } -function scanArgumentsFromRecipe( +async function prepareScanArgumentsFromRecipe( recipe: JsonValue | undefined, parentScanId: string, - validationPromptFile?: string, -): ScanArguments { + { + scanPromptFile, + validationPromptFile, + }: Pick, + directory: string, +): Promise { if (recipe === undefined || !isJsonObject(recipe)) { throw new CodexSecurityError( "This scan does not have a saved launch recipe.", ); } - if (recipe["requiresScanPrompt"] === true) { + if (recipe["requiresScanPrompt"] === true && scanPromptFile === undefined) { throw new CodexSecurityError( - "This scan used additional instructions that are not retained. Start a new scan with --scan-prompt-file or --config to supply them again.", + "This scan used additional instructions that are not retained. Supply --scan-prompt-file to rerun it.", ); } if ( @@ -4549,6 +4694,16 @@ function scanArgumentsFromRecipe( "The saved scan recipe contains deep scan settings for a standard scan.", ); } + const prompts = await resolveScanPrompts( + { scanPromptFile, validationPromptFile }, + repository, + directory, + ); + if (recipe["requiresScanPrompt"] === true && !prompts.scanPrompt?.trim()) { + throw new CodexSecurityError( + "This scan used additional instructions. The --scan-prompt-file must not be empty.", + ); + } return { repository, auth: auth.data ?? DEFAULT_SCAN_AUTH, @@ -4560,8 +4715,10 @@ function scanArgumentsFromRecipe( : kind === "working_tree" ? DiffTarget.workingTree({ base: reference ?? "HEAD" }) : "repository", - knowledgeBasePaths, - validationPromptFile, + knowledgeBasePaths: knowledgeBasePaths.map((path) => + resolveCliPath(directory, path), + ), + ...prompts, mode, ...deepScan.data, archiveExisting: false, @@ -4602,6 +4759,7 @@ function validateCliArguments( "logout", "serve", "info", + "init", ].includes(value), ); if (commandIndex < 0) return undefined; @@ -4677,6 +4835,7 @@ function validateCliArguments( "model", "reasoningEffort", "nextStep", + "configuration", ]); for (let index = 0; index < argv.length; index += 1) { const argument = argv[index]!; @@ -6456,7 +6615,7 @@ async function executeScan( } security = dependencies.createSecurity(config); const options: ScanOptions = { - ...scanSettings(arguments_), + ...pickScanSettings(arguments_), ...(arguments_.workflowId === undefined ? {} : { workflowId: arguments_.workflowId }), @@ -6784,14 +6943,6 @@ async function executeScan( verified: effectivePreflight.authentication.verified, }); progress?.stopTimer(); - if (arguments_.projectConfig !== undefined) { - for (const [name, key] of DEEP_SCAN_SETTINGS) { - const source = preflight.deepScanSources?.[name]; - if (source === undefined || source === "override") continue; - const field = name === "subagents" ? "subagents_per_worker" : key; - arguments_.projectConfig.sources[`scan.deep.${field}`] = source; - } - } return { exitCode: 0, data: { @@ -6800,7 +6951,13 @@ async function executeScan( ...(arguments_.projectConfig === undefined ? {} : { - projectConfig: arguments_.projectConfig, + projectConfig: { + ...arguments_.projectConfig, + sources: configurationSources( + arguments_.projectConfig.sources, + preflight.deepScanSources, + ), + }, scanPromptFile: arguments_.scanPromptFile, validationPromptFile: arguments_.validationPromptFile, failOnSeverity: arguments_.failureSeverity, @@ -7357,6 +7514,18 @@ function quoteCliPath(path: string): string { : `'${path.replaceAll("'", `'"'"'`)}'`; } +async function selectedProjectConfig( + file: string | undefined, + dependencies: Pick, +) { + const selected = + file ?? + environmentValue(dependencies.environment, "CODEX_SECURITY_PROJECT_CONFIG"); + return selected === undefined + ? undefined + : readProjectConfig(selected, dependencies.currentDirectory()); +} + function resolveCliScope( configured: ProjectScope | undefined, overrides: { @@ -7366,8 +7535,11 @@ function resolveCliScope( head?: string; base?: string; }, -): { target?: ScanTarget; sources: ProjectConfigProvenance["sources"] } { - const sources: ProjectConfigProvenance["sources"] = {}; +): { + target?: ScanTarget; + sources: Partial>; +} { + const sources: Partial> = {}; const explicitScopes = Number(!!overrides.paths?.length) + Number(overrides.diff !== undefined) + @@ -7376,6 +7548,14 @@ function resolveCliScope( throw new ConfigurationError( "--path, --diff, and --working-tree are mutually exclusive.", ); + if ( + explicitScopes === 0 && + overrides.workingTree !== false && + overrides.head === undefined && + overrides.base === undefined + ) { + return { sources }; + } let scope = configured; let changed = false; sources["scan.scope"] = scope === undefined ? "default" : "project"; @@ -7395,6 +7575,8 @@ function resolveCliScope( changed = true; sources["scan.scope"] = "cli"; } + // A ref-only override refines the selected project scope; it does not take + // ownership of the whole scope variant. if (overrides.head !== undefined) { if (scope === undefined || !("diff" in scope)) throw new ConfigurationError("--head requires --diff."); @@ -7493,13 +7675,17 @@ export function parseCodexOverrides( } cursor[final] = parsed; } - if ( - (isExternalModelProvider(provider) || provider === "amazon-bedrock") && - scanModel(mergeCodexOverrides(defaults ?? {}, result)) === undefined - ) { - throw new CodexSecurityError( - `--model is required when using --provider ${provider}`, + if (isExternalModelProvider(provider) || provider === "amazon-bedrock") { + const selectedModel = scanModel( + mergeCodexOverrides(defaults ?? {}, result), ); + if (typeof selectedModel !== "string" || !selectedModel.trim()) { + throw new CodexSecurityError( + selectedModel === undefined + ? `--model is required when using --provider ${provider}` + : `--model must be a nonempty string when using --provider ${provider}`, + ); + } } return result; } diff --git a/sdk/typescript/src/component-scan.ts b/sdk/typescript/src/component-scan.ts index 7b21b9679..f3bf1eb52 100644 --- a/sdk/typescript/src/component-scan.ts +++ b/sdk/typescript/src/component-scan.ts @@ -23,6 +23,7 @@ import { safeErrorMessage } from "./errors.js"; import type { CoverageCompleteness, Finding } from "./models.js"; import type { ScanResult } from "./result.js"; import type { ScanActivity } from "./scan-activity.js"; +import type { ScanSettings } from "./scan-settings.js"; import type { ScanProgress, ScanWorkerStatus } from "./worker-progress.js"; import { matchScanFindings, @@ -39,14 +40,7 @@ export interface ComponentScanOptions { planOnly?: boolean; workers?: number; config?: CodexSecurityConfig; - scanOptions?: Pick< - ScanOptions, - | "auth" - | "knowledgeBasePaths" - | "scanPrompt" - | "postScanPrompt" - | "maxCostUsd" - >; + scanOptions?: Omit; signal?: AbortSignal; createSecurity?: ( config: CodexSecurityConfig, @@ -120,6 +114,7 @@ export interface ComponentScanResult { findingCount?: number; sourceFindingCount?: number; deduplication?: ComponentDeduplicationSummary; + policyFailed?: boolean; } export async function runComponentScans( @@ -221,7 +216,7 @@ export async function runComponentScans( const result = await security.run(repository, { ...options.scanOptions, ...observers, - mode: "standard", + mode: options.scanOptions?.mode ?? "standard", target: receipt.paths, outputDir: receipt.outputDir, signal: options.signal, @@ -278,6 +273,7 @@ export async function runComponentScans( : join(output, "retry-components.json"); if (retryPlanPath !== undefined) await writeJson(retryPlanPath, { components: retryComponents }); + const failureSeverity = options.scanOptions?.failureSeverity; const summary = { ...base, completed: receipts.filter(({ status }) => status === "completed").length, @@ -293,6 +289,13 @@ export async function runComponentScans( 0, ), deduplication, + ...(failureSeverity === undefined + ? {} + : { + policyFailed: [...results.values()].some((result) => + result.hasFindingsAtOrAbove(failureSeverity), + ), + }), }; await writeJson(summary.findingsPath, { documentType: "codex-security.component-findings", diff --git a/sdk/typescript/src/config-path.ts b/sdk/typescript/src/config-path.ts new file mode 100644 index 000000000..c745e95ce --- /dev/null +++ b/sdk/typescript/src/config-path.ts @@ -0,0 +1,12 @@ +import { resolve } from "node:path"; +import { expandHome } from "./runtime.js"; + +/** A config, SDK, or CLI path anchored to its input directory. */ +export type AbsolutePath = string & { readonly __absolutePath: unique symbol }; + +export function resolveConfigPath( + directory: string, + value: string, +): AbsolutePath { + return resolve(directory, expandHome(value)) as AbsolutePath; +} diff --git a/sdk/typescript/src/deep-config.ts b/sdk/typescript/src/deep-config.ts index 176a50eec..7fb83291c 100644 --- a/sdk/typescript/src/deep-config.ts +++ b/sdk/typescript/src/deep-config.ts @@ -1,5 +1,5 @@ -import { readFile, realpath } from "node:fs/promises"; -import { basename, dirname, join } from "node:path"; +import { lstat, readFile, realpath } from "node:fs/promises"; +import { basename, dirname, join, resolve } from "node:path"; import { parse as parseToml, type TomlTable } from "smol-toml"; import { writeCodexConfig, type JsonObject } from "./config.js"; import { DEFAULT_DEEP_SCAN_SETTINGS } from "./deep-scan-defaults.js"; @@ -10,7 +10,7 @@ import { DeepScanSettingsSchema, type DeepScanOptions, } from "./scan-settings.js"; -import type { ScanMode } from "./targets.js"; +import type { ScanMode } from "./scan-modes.js"; export type DeepScanSources = Record< keyof DeepScanOptions, @@ -21,34 +21,38 @@ export interface ResolvedDeepScanConfig { sources: DeepScanSources; source: string; document: TomlTable; - hasOverrides: boolean; + overrides: DeepScanOptions; } export function deepScanOptions( options: DeepScanOptions & { mode?: ScanMode }, ): DeepScanOptions { const selected: DeepScanOptions = {}; - for (const [name, , minimum] of DEEP_SCAN_SETTINGS) { + for (const [name] of DEEP_SCAN_SETTINGS) { const value = options[name]; if (value === undefined) continue; if ((options.mode ?? DEFAULT_SCAN_MODE) !== "deep") { throw new CodexSecurityError("Deep scan settings require deep mode."); } - if (!DeepScanSettingsSchema.shape[name].safeParse(value).success) { - if (name === "maxTimeHours") { - throw new CodexSecurityError( - "Deep scan maxTimeHours must be a positive number no greater than 96.", - ); - } - throw new CodexSecurityError( - `Deep scan ${name} must be ${minimum === 0 ? "a non-negative" : "a positive"} integer.`, - ); - } - selected[name] = value; + selected[name] = requireDeepScanValue(name, value, name); } return selected; } +function requireDeepScanValue( + name: keyof DeepScanOptions, + value: unknown, + label: string, +): number { + const parsed = DeepScanSettingsSchema.shape[name].unwrap().safeParse(value); + if (!parsed.success) { + throw new CodexSecurityError( + `Deep scan ${label} ${parsed.error.issues[0]!.message}.`, + ); + } + return parsed.data; +} + export async function resolveDeepScanConfig( options: DeepScanOptions, source: string, @@ -80,30 +84,35 @@ export async function resolveDeepScanConfig( `Unknown Codex Security Deep Scan configuration ${unknown.join(", ")} in ${source}.`, ); } - const values: Record = { ...DEFAULT_DEEP_SCAN_SETTINGS }; + const settings = { + ...DEFAULT_DEEP_SCAN_SETTINGS, + } as Required; const sources = {} as DeepScanSources; for (const [name, key] of DEEP_SCAN_SETTINGS) { - if (Object.hasOwn(configured, key)) values[name] = configured[key]; - if (name === "workers" && values[name] === "auto") - values[name] = DEFAULT_DEEP_SCAN_SETTINGS.workers; - if (explicit[name] !== undefined) values[name] = explicit[name]; + let value = Object.hasOwn(configured, key) + ? configured[key] + : DEFAULT_DEEP_SCAN_SETTINGS[name]; + if (name === "workers" && value === "auto") + value = DEFAULT_DEEP_SCAN_SETTINGS.workers; + if (explicit[name] !== undefined) value = explicit[name]; sources[name] = explicit[name] !== undefined ? "override" : Object.hasOwn(configured, key) ? "legacy" : "default"; + settings[name] = requireDeepScanValue( + name, + value, + sources[name] === "legacy" ? `${key} in ${source}` : name, + ); } - const settings = deepScanOptions({ - ...values, - mode: "deep", - } as DeepScanOptions & { mode: ScanMode }) as Required; return { settings, sources, source, document, - hasOverrides: Object.keys(explicit).length > 0, + overrides: explicit, }; } @@ -112,30 +121,67 @@ export async function writeDeepScanConfig( resolved: ResolvedDeepScanConfig, ): Promise { const [source, target] = await Promise.all([ - canonicalConfigPath(resolved.source).catch(() => null), - canonicalConfigPath(destination).catch(() => null), + canonicalConfigPath(resolved.source), + runtimeConfigPath(destination), ]); let document = resolved.document; - if (source !== null && source === target) { - if (!resolved.hasOverrides) return; + const sameFile = source === target; + if (sameFile) { + if (Object.keys(resolved.overrides).length === 0) return; document = await readDeepScanDocument(destination); } + // An isolated runtime needs a complete snapshot. An ambient file keeps + // inherited defaults unset so future releases can still update them. + const settings = sameFile ? resolved.overrides : resolved.settings; + const retainAmbientSettings = + sameFile && + DEEP_SCAN_SETTINGS.some(([name]) => settings[name] === undefined); await writeCodexConfig(destination, { ...document, - deep_scan: Object.fromEntries( - DEEP_SCAN_SETTINGS.map(([name, key]) => [key, resolved.settings[name]]), - ), + deep_scan: { + ...(retainAmbientSettings + ? (document["deep_scan"] as TomlTable | undefined) + : {}), + ...Object.fromEntries( + DEEP_SCAN_SETTINGS.filter(([name]) => settings[name] !== undefined).map( + ([name, key]) => [key, settings[name]], + ), + ), + }, } as JsonObject); } -async function canonicalConfigPath(path: string): Promise { +async function runtimeConfigPath(path: string): Promise { try { - return await realpath(path); + return await canonicalConfigPath(path); } catch (error) { - const parent = dirname(path); - if ((error as NodeJS.ErrnoException).code !== "ENOENT" || parent === path) + if ( + (error as NodeJS.ErrnoException).code !== "ELOOP" || + !(await lstat(path)).isSymbolicLink() + ) throw error; - return join(await canonicalConfigPath(parent), basename(path)); + // A stale cyclic file link is replaced by writeCodexConfig's atomic rename. + // Errors resolving its parent (or the ambient source) still fail the write. + return join(await canonicalConfigPath(dirname(path)), basename(path)); + } +} + +async function canonicalConfigPath(path: string): Promise { + let existing = resolve(path); + const missing: string[] = []; + while (true) { + try { + return join(await realpath(existing), ...missing); + } catch (error) { + const parent = dirname(existing); + if ( + (error as NodeJS.ErrnoException).code !== "ENOENT" || + parent === existing + ) + throw error; + missing.unshift(basename(existing)); + existing = parent; + } } } diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 9d997a134..7a4d66dbd 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -135,4 +135,5 @@ export { validateMode, } from "./targets.js"; export type { NormalizedTarget, ScanMode, ScanTarget } from "./targets.js"; +export type { AbsolutePath } from "./config-path.js"; export { BUNDLED_PLUGIN_VERSION, VERSION } from "./version.js"; diff --git a/sdk/typescript/src/multiscan.ts b/sdk/typescript/src/multiscan.ts index c63019d2e..740e9edbd 100644 --- a/sdk/typescript/src/multiscan.ts +++ b/sdk/typescript/src/multiscan.ts @@ -25,6 +25,8 @@ import { safeErrorMessage, ScanCostLimitExceededError } from "./errors.js"; import type { CoverageDocument } from "./models.js"; import { requireSecureOutputAncestry } from "./runtime.js"; import type { ScanMode } from "./targets.js"; +import type { ScanSettings } from "./scan-settings.js"; +import { workflowDigest } from "./finding-workflow.js"; import { resolveTrustedExecutable } from "./trusted-executable.js"; const execFile = promisify(execFileCallback); @@ -56,6 +58,7 @@ interface MultiscanReceipt extends MultiscanTask { cost?: ScanCost; error?: string; warning?: string; + policyFailed?: boolean; } export interface MultiscanOptions { @@ -70,6 +73,7 @@ export interface MultiscanOptions { scanPrompt?: string; validationPrompt?: string; postScanPrompt?: string; + scanOptionsByMode?: Partial>; config: CodexSecurityConfig; createSecurity( config: CodexSecurityConfig, @@ -95,6 +99,7 @@ export interface MultiscanResult { failed: number; skipped: number; resultsPath: string; + policyFailed?: boolean; } export async function runMultiscan( @@ -145,6 +150,10 @@ async function runCampaign( const pending: MultiscanTask[] = []; let completed = 0; let incomplete = 0; + let policyFailed = false; + const hasPolicy = Object.values(options.scanOptionsByMode ?? {}).some( + (settings) => settings.failureSeverity !== undefined, + ); for (const task of tasks) { const receipt = receipts.get(task.id.toLowerCase()); if (receipt === undefined) { @@ -167,6 +176,7 @@ async function runCampaign( (await hasArtifacts(artifactOutput)) ) { if (receipt.status === "completed") { + policyFailed ||= receipt.policyFailed === true; completed += 1; continue; } @@ -178,6 +188,7 @@ async function runCampaign( outputDir: artifactOutput, }); if (coverage !== undefined) { + policyFailed ||= receipt.policyFailed === true; incomplete += 1; notifyProgress(options, { repository: task.id, @@ -201,6 +212,7 @@ async function runCampaign( failed: 0, skipped, resultsPath: ledger, + ...(hasPolicy ? { policyFailed } : {}), }; } @@ -228,6 +240,7 @@ async function runCampaign( notifyProgress(options, { ...progress, status: "started" }); let failure: string | undefined; let warning: string | undefined; + let attemptPolicyFailed: boolean | undefined; let coverage: CoverageDocument["completeness"] | undefined; let cost: Readonly | null = null; let exhaustedBudget = false; @@ -252,10 +265,15 @@ async function runCampaign( throw new Error("Multiscan scope escapes its repository."); } } - const scanPrompt = [options.scanPrompt?.trim(), task.prompt] + const scanSettings = options.scanOptionsByMode?.[task.mode]; + const scanPrompt = [ + options.scanPrompt?.trim() ?? scanSettings?.scanPrompt?.trim(), + task.prompt, + ] .filter(Boolean) .join("\n\n"); const result = await security.run(checkout, { + ...scanSettings, ...(task.scope === undefined ? {} : { target: [task.scope] }), ...(options.knowledgeBasePaths?.length ? { knowledgeBasePaths: options.knowledgeBasePaths } @@ -281,6 +299,11 @@ async function runCampaign( ...(options.signal === undefined ? {} : { signal: options.signal }), }); cost = result.cost; + if (scanSettings?.failureSeverity !== undefined) { + attemptPolicyFailed = result.hasFindingsAtOrAbove( + scanSettings.failureSeverity, + ); + } coverage = result.coverage.completeness; if (coverage !== "complete") { if (!(await hasArtifacts(scanDir))) { @@ -317,6 +340,9 @@ async function runCampaign( ...(cost === null ? {} : { cost }), ...(failure === undefined ? {} : { error: failure }), ...(warning === undefined ? {} : { warning }), + ...(attemptPolicyFailed === undefined + ? {} + : { policyFailed: attemptPolicyFailed }), })}\n`, ); notifyProgress(options, { @@ -326,6 +352,7 @@ async function runCampaign( ...(warning === undefined ? {} : { warning }), }); if (failure === undefined) { + policyFailed ||= attemptPolicyFailed === true; if (warning === undefined) completed += 1; else incomplete += 1; break; @@ -360,6 +387,7 @@ async function runCampaign( failed, skipped, resultsPath: ledger, + ...(hasPolicy ? { policyFailed } : {}), }; } @@ -631,7 +659,12 @@ async function ensureManifest( tasks: MultiscanTask[], options: Pick< MultiscanOptions, - "scanPrompt" | "validationPrompt" | "postScanPrompt" | "maxCostUsd" + | "scanPrompt" + | "validationPrompt" + | "postScanPrompt" + | "maxCostUsd" + | "scanOptionsByMode" + | "config" >, ): Promise { const expected = `${JSON.stringify( @@ -650,6 +683,14 @@ async function ensureManifest( ...(options.maxCostUsd === undefined ? {} : { maxCostUsd: options.maxCostUsd }), + ...(options.scanOptionsByMode === undefined + ? {} + : { + configurationDigest: workflowDigest({ + scanOptions: options.scanOptionsByMode, + codex: options.config.codexOverrides, + }), + }), }, null, 2, diff --git a/sdk/typescript/src/project-config.ts b/sdk/typescript/src/project-config.ts index 560d9e896..ad950a293 100644 --- a/sdk/typescript/src/project-config.ts +++ b/sdk/typescript/src/project-config.ts @@ -8,6 +8,9 @@ import { type JsonObject, } from "./config.js"; import { ConfigurationError } from "./errors.js"; +import { DEFAULT_DEEP_SCAN_SETTINGS } from "./deep-scan-defaults.js"; +import { resolveConfigPath, type AbsolutePath } from "./config-path.js"; +import { deepScanOptions, type DeepScanSources } from "./deep-config.js"; import { projectConfigJsonSchema, type ProjectConfigInput, @@ -18,7 +21,7 @@ import { DEFAULT_SCAN_AUTH, DEFAULT_SCAN_MODE, DEEP_SCAN_SETTINGS, - scanSettings, + pickScanSettings, type DeepScanOptions, type ResolvedScanSettings, type ScanSettings, @@ -38,13 +41,60 @@ export interface ProjectConfigSource { export interface ResolvedProjectConfig { config: { codexOverrides: JsonObject }; options: ResolvedScanSettings; + sources: ConfigurationSources; projectConfig?: ProjectConfigProvenance; } export type ConfigurationSource = "default" | "legacy" | "project" | "cli"; +const PROJECT_SETTING_KEYS = { + auth: "auth", + mode: "scan.mode", + target: "scan.scope", + knowledgeBasePaths: "scan.knowledge_base", + scanPromptFile: "scan.instructions_file", + validationPromptFile: "scan.validation_file", + outputDir: "output.directory", + failureSeverity: "policy.fail_on_severity", + maxCostUsd: "limits.max_cost_usd_per_scan", +} as const satisfies Partial>; +type SettingProvenanceKey = + (typeof PROJECT_SETTING_KEYS)[keyof typeof PROJECT_SETTING_KEYS]; +export type ScopeProvenanceKey = + | "scan.scope" + | "scan.scope.diff.head" + | "scan.scope.working_tree.base"; +export type ProvenanceKey = + | SettingProvenanceKey + | ScopeProvenanceKey + | `scan.deep.${(typeof DEEP_SCAN_SETTINGS)[number][2]}` + | `codex.${string}`; +export type ConfigurationSources = Readonly< + Record & + Partial> +>; export interface ProjectConfigProvenance { path: string; - sources: Record; + sources: ConfigurationSources; +} + +/** Build a complete, immutable source map after layer and preflight resolution. */ +export function configurationSources( + values: Partial> = {}, + deepSources?: DeepScanSources, +): ConfigurationSources { + const sources = { + ...Object.fromEntries( + Object.values(PROJECT_SETTING_KEYS).map((key) => [key, "default"]), + ), + ...values, + } as Record & + Partial>; + for (const [name, , key] of DEEP_SCAN_SETTINGS) { + const source = deepSources?.[name]; + if (source !== undefined && source !== "override") + sources[`scan.deep.${key}`] = source; + } + return Object.freeze(sources); } export async function loadProjectConfig( @@ -75,12 +125,7 @@ export async function readProjectConfig( directory = process.cwd(), ): Promise { const path = resolve(directory, expandHome(file)); - const extension = extname(path).toLowerCase(); - if (![".yaml", ".yml", ".json"].includes(extension)) { - throw new ConfigurationError( - "Project configuration must be a .yaml, .yml, or .json file.", - ); - } + const extension = projectConfigExtension(path); let text: string; try { text = await readFile(path, "utf8"); @@ -97,7 +142,8 @@ export async function readProjectConfig( } else { const document = parseDocument(text, { prettyErrors: false }); if (document.errors.length > 0) throw document.errors[0]; - value = document.toJS({ maxAliasCount: -1 }); + // Keep YAML's expansion guard while allowing repeated native profiles. + value = document.toJS({ maxAliasCount: 10_000 }); } } catch (error) { throw new ConfigurationError( @@ -109,6 +155,51 @@ export async function readProjectConfig( return { path, directory: dirname(path), input: value }; } +function projectConfigExtension(path: string): string { + const extension = extname(path).toLowerCase(); + if (![".yaml", ".yml", ".json"].includes(extension)) { + throw new ConfigurationError( + "Project configuration must be a .yaml, .yml, or .json file.", + ); + } + return extension; +} + +export function projectConfigStarter(path: string): string { + const schema = + "./node_modules/@openai/codex-security/schemas/project-config.schema.json"; + if (projectConfigExtension(path) === ".json") + return `${JSON.stringify({ $schema: schema }, null, 2)}\n`; + return [ + "# This file is trusted like CLI options. Keep it outside untrusted inputs.", + `$schema: ${schema}`, + "", + "# Uncomment the settings you want to override. Defaults remain unpinned.", + `# auth: ${DEFAULT_SCAN_AUTH}`, + "# scan:", + `# mode: ${DEFAULT_SCAN_MODE}`, + "# scope:", + "# paths: [src] # Relative to each selected repository.", + "# knowledge_base: [] # Paths relative to this file.", + "# instructions_file: instructions.md", + "# validation_file: validation.md # Standard mode only.", + "# deep: # Used when mode is deep.", + ...DEEP_SCAN_SETTINGS.map( + ([name, , key]) => `# ${key}: ${DEFAULT_DEEP_SCAN_SETTINGS[name]}`, + ), + "# codex:", + `# model: ${DEFAULT_CODEX_CONFIG["model"]}`, + `# model_reasoning_effort: ${DEFAULT_CODEX_CONFIG["model_reasoning_effort"]}`, + "# limits:", + "# max_cost_usd_per_scan: 10 # Optional limit per scan attempt.", + "# policy:", + "# fail_on_severity: high # Omitted by default (report only).", + "# output:", + "# directory: ../scan-results # Outside the selected repositories.", + "", + ].join("\n"); +} + function requireProjectConfig( value: unknown, path?: string, @@ -133,16 +224,12 @@ export function resolveScanSettings( project: ProjectConfigSource | undefined, overrides: Partial & { codexOverrides?: JsonObject }, directory: string, + scopeSources: Partial> = {}, ): ResolvedProjectConfig { const file = project?.input; - const sources: Record = { - auth: "default", - "scan.mode": "default", - "scan.scope": "default", - "scan.knowledge_base": "default", - }; + const sources: Partial> = {}; const choose = ( - key: string, + key: ProvenanceKey, configured: T | undefined, explicit: T | undefined, ): T | undefined => { @@ -156,21 +243,17 @@ export function resolveScanSettings( } return undefined; }; - const filePath = (value: string | undefined): string | undefined => + const projectDirectory = project?.directory ?? directory; + const filePath = (value: string | undefined): AbsolutePath | undefined => value === undefined ? undefined - : resolve(project!.directory, expandHome(value)); - const cliPath = (value: string | undefined): string | undefined => - value === undefined ? undefined : resolve(directory, expandHome(value)); + : resolveConfigPath(projectDirectory, value); + const cliPath = (value: string | undefined): AbsolutePath | undefined => + value === undefined ? undefined : resolveConfigPath(directory, value); const mode = choose("scan.mode", file?.scan?.mode, overrides.mode) ?? DEFAULT_SCAN_MODE; - if ( - mode !== "deep" && - DEEP_SCAN_SETTINGS.some(([name]) => overrides[name] !== undefined) - ) { - throw new ConfigurationError("Deep scan settings require --mode deep."); - } + const explicitDeep = deepScanOptions({ ...overrides, mode }); const target = choose( "scan.scope", @@ -180,12 +263,11 @@ export function resolveScanSettings( const configuredDeep = file?.scan?.deep; const deep: DeepScanOptions = {}; if (mode === "deep") { - for (const [name, key] of DEEP_SCAN_SETTINGS) { - const field = name === "subagents" ? "subagents_per_worker" : key; + for (const [name, , field] of DEEP_SCAN_SETTINGS) { const value = choose( `scan.deep.${field}`, configuredDeep?.[field], - overrides[name], + explicitDeep[name], ); if (value !== undefined) deep[name] = value; } @@ -198,15 +280,15 @@ export function resolveScanSettings( const recordNativeSources = ( value: JsonObject, source: ConfigurationSource, - prefix = "codex", + prefix: "codex" | `codex.${string}` = "codex", ) => { for (const [key, item] of Object.entries(value)) { - const path = `${prefix}.${key}`; + const path: `codex.${string}` = `${prefix}.${key}`; if (item !== null && typeof item === "object" && !Array.isArray(item)) { delete sources[path]; recordNativeSources(item, source, path); } else { - for (const existing of Object.keys(sources)) { + for (const existing of Object.keys(sources) as ProvenanceKey[]) { if (existing.startsWith(`${path}.`)) delete sources[existing]; } sources[path] = source; @@ -219,11 +301,15 @@ export function resolveScanSettings( const knowledgeBasePaths = choose( "scan.knowledge_base", - file?.scan?.knowledge_base?.map((value) => filePath(value)!), - overrides.knowledgeBasePaths?.map((value) => cliPath(value)!), + file?.scan?.knowledge_base?.map((value) => + resolveConfigPath(projectDirectory, value), + ), + overrides.knowledgeBasePaths?.map((value) => + resolveConfigPath(directory, value), + ), ) ?? []; const settings: ResolvedScanSettings = { - ...scanSettings(overrides), + ...pickScanSettings(overrides), auth: choose("auth", file?.auth, overrides.auth) ?? DEFAULT_SCAN_AUTH, mode, target, @@ -238,6 +324,7 @@ export function resolveScanSettings( filePath(file?.scan?.validation_file), cliPath(overrides.validationPromptFile), ), + postScanPromptFile: cliPath(overrides.postScanPromptFile), outputDir: choose( "output.directory", filePath(file?.output?.directory), @@ -255,12 +342,14 @@ export function resolveScanSettings( ), ...deep, }; + const resolvedSources = configurationSources({ ...sources, ...scopeSources }); return { config: { codexOverrides }, options: settings, + sources: resolvedSources, ...(project?.path === undefined ? {} - : { projectConfig: { path: project.path, sources } }), + : { projectConfig: { path: project.path, sources: resolvedSources } }), }; } diff --git a/sdk/typescript/src/prompt-files.ts b/sdk/typescript/src/prompt-files.ts index d737b0580..4e96405e0 100644 --- a/sdk/typescript/src/prompt-files.ts +++ b/sdk/typescript/src/prompt-files.ts @@ -13,12 +13,21 @@ import { CodexSecurityError } from "./errors.js"; import { expandHome } from "./runtime.js"; import type { ScanPromptSettings } from "./scan-settings.js"; +type ResolvedScanPrompts = Pick< + ScanPromptSettings, + "scanPrompt" | "validationPrompt" | "postScanPrompt" +> & { + scanPromptFile: undefined; + validationPromptFile: undefined; + postScanPromptFile: undefined; +}; + /** Resolve selected files once; an inline SDK prompt overrides its file. */ export async function resolveScanPrompts( options: ScanPromptSettings, repository: string, directory = process.cwd(), -): Promise { +): Promise { const read = async (inline: string | undefined, file: string | undefined) => inline !== undefined || file === undefined ? inline @@ -48,6 +57,8 @@ export async function resolveScanPrompts( options.postScanPrompt !== undefined || postScanPrompt?.trim() ? postScanPrompt : undefined, + // Spreading the resolved prompts back into options must clear file inputs, + // including blank files, so later preparation does not read them again. scanPromptFile: undefined, validationPromptFile: undefined, postScanPromptFile: undefined, diff --git a/sdk/typescript/src/result.ts b/sdk/typescript/src/result.ts index c4efd8bdf..1fb5d8c19 100644 --- a/sdk/typescript/src/result.ts +++ b/sdk/typescript/src/result.ts @@ -8,7 +8,7 @@ import type { SeverityLevel, } from "./models.js"; import { estimateScanCost, type ScanCost } from "./cost.js"; -import { meetsSeverity } from "./scan-settings.js"; +import { meetsSeverity, severityThresholdRank } from "./scan-settings.js"; export interface TurnResultMetadata { id?: string; @@ -114,6 +114,7 @@ export class ScanResult { } public hasFindingsAtOrAbove(threshold: SeverityLevel): boolean { + severityThresholdRank(threshold); return this.findings.findings.some((finding) => meetsSeverity(finding, threshold), ); diff --git a/sdk/typescript/src/scan-modes.ts b/sdk/typescript/src/scan-modes.ts new file mode 100644 index 000000000..75925283d --- /dev/null +++ b/sdk/typescript/src/scan-modes.ts @@ -0,0 +1,2 @@ +export const SCAN_MODES = ["standard", "deep"] as const; +export type ScanMode = (typeof SCAN_MODES)[number]; diff --git a/sdk/typescript/src/scan-settings.ts b/sdk/typescript/src/scan-settings.ts index 883b7c772..9f46321d8 100644 --- a/sdk/typescript/src/scan-settings.ts +++ b/sdk/typescript/src/scan-settings.ts @@ -1,12 +1,13 @@ import { z } from "zod"; import { DEFAULT_DEEP_SCAN_SETTINGS } from "./deep-scan-defaults.js"; +import { ConfigurationError } from "./errors.js"; import type { Finding, SeverityLevel } from "./models.js"; +import type { AbsolutePath } from "./config-path.js"; +import { SCAN_MODES, type ScanMode } from "./scan-modes.js"; import type { ScanTarget } from "./targets.js"; export const SCAN_AUTH_MODES = ["auto", "chatgpt", "api-key"] as const; export type ScanAuthMode = (typeof SCAN_AUTH_MODES)[number]; -export const SCAN_MODES = ["standard", "deep"] as const; -export type ScanMode = (typeof SCAN_MODES)[number]; export const DEFAULT_SCAN_AUTH = "auto"; export const DEFAULT_SCAN_MODE = "standard"; export const REPORTABLE_SEVERITIES = [ @@ -22,41 +23,75 @@ export const SCAN_SEVERITIES = [ export const FailureSeveritySchema = z.enum(REPORTABLE_SEVERITIES); export type FailureSeverity = z.infer; +// SDK name, legacy TOML key, project key, and CLI flag (when exposed). export const DEEP_SCAN_SETTINGS = [ - ["workers", "workers", 1], - ["subagents", "subagents", 0], - ["stopAfterNoNew", "stop_after_no_new", 1], - ["stopAfterConsecutiveErrors", "stop_after_consecutive_errors", 1], - ["maxDiscoveryRuns", "max_discovery_runs", 1], - ["maxTimeHours", "max_time_hours", 0], + ["workers", "workers", "workers", "--workers"], + ["subagents", "subagents", "subagents_per_worker", "--subagents"], + [ + "stopAfterNoNew", + "stop_after_no_new", + "stop_after_no_new", + "--stop-after-no-new", + ], + [ + "stopAfterConsecutiveErrors", + "stop_after_consecutive_errors", + "stop_after_consecutive_errors", + null, + ], + [ + "maxDiscoveryRuns", + "max_discovery_runs", + "max_discovery_runs", + "--max-discovery-runs", + ], + ["maxTimeHours", "max_time_hours", "max_time_hours", "--max-time-hours"], ] as const; +const positiveIntegerError = "must be a positive integer"; +const positiveInteger = z + .number({ error: positiveIntegerError }) + .int({ error: positiveIntegerError }) + .positive({ error: positiveIntegerError }); +const nonnegativeIntegerError = "must be a non-negative integer"; +const nonnegativeInteger = z + .number({ error: nonnegativeIntegerError }) + .int({ error: nonnegativeIntegerError }) + .nonnegative({ error: nonnegativeIntegerError }); +const maximumHours = 96; +const hoursError = `must be a positive number no greater than ${maximumHours}`; + export const DeepScanSettingsSchema = z.strictObject({ - workers: z.number().int().positive().optional().meta({ + workers: positiveInteger.optional().meta({ default: DEFAULT_DEEP_SCAN_SETTINGS.workers, description: "Maximum concurrent deep-scan discovery workers.", }), - subagents: z.number().int().nonnegative().optional().meta({ + subagents: nonnegativeInteger.optional().meta({ default: DEFAULT_DEEP_SCAN_SETTINGS.subagents, description: "Subagents available to each deep-scan worker. Zero is valid.", }), - stopAfterNoNew: z.number().int().positive().optional().meta({ + stopAfterNoNew: positiveInteger.optional().meta({ default: DEFAULT_DEEP_SCAN_SETTINGS.stopAfterNoNew, description: "Stop after this many runs find no new issues.", }), - stopAfterConsecutiveErrors: z.number().int().positive().optional().meta({ + stopAfterConsecutiveErrors: positiveInteger.optional().meta({ default: DEFAULT_DEEP_SCAN_SETTINGS.stopAfterConsecutiveErrors, description: "Stop after this many consecutive discovery errors.", }), - maxDiscoveryRuns: z.number().int().positive().optional().meta({ + maxDiscoveryRuns: positiveInteger.optional().meta({ default: DEFAULT_DEEP_SCAN_SETTINGS.maxDiscoveryRuns, description: "Maximum deep-scan discovery runs.", }), - maxTimeHours: z.number().positive().max(96).optional().meta({ - default: DEFAULT_DEEP_SCAN_SETTINGS.maxTimeHours, - description: - "Maximum deep-scan discovery hours (default: 96; maximum: 96).", - }), + maxTimeHours: z + .number({ error: hoursError }) + .positive({ error: hoursError }) + .max(maximumHours, { error: hoursError }) + .optional() + .meta({ + default: DEFAULT_DEEP_SCAN_SETTINGS.maxTimeHours, + description: + "Maximum deep-scan discovery hours (default: 96; maximum: 96).", + }), }); export type DeepScanOptions = z.infer; @@ -87,7 +122,11 @@ export interface ResolvedScanSettings extends ScanSettings { auth: ScanAuthMode; mode: ScanMode; target: ScanTarget; - knowledgeBasePaths: string[]; + knowledgeBasePaths: AbsolutePath[]; + scanPromptFile?: AbsolutePath; + validationPromptFile?: AbsolutePath; + postScanPromptFile?: AbsolutePath; + outputDir?: AbsolutePath; } export type ScanPromptSettings = Pick< @@ -100,7 +139,8 @@ export type ScanPromptSettings = Pick< | "postScanPromptFile" >; -export function scanSettings(settings: ScanSettings): ScanSettings { +/** Pick defined scan settings without copying callbacks or workflow controls. */ +export function pickScanSettings(settings: ScanSettings): ScanSettings { const keys = [ "target", ...Object.keys(ScanSettingsSchema.shape), @@ -116,6 +156,17 @@ export function meetsSeverity( finding: Pick, threshold: SeverityLevel, ): boolean { + const thresholdRank = severityThresholdRank(threshold); const severity = SCAN_SEVERITIES.indexOf(finding.severity.level); - return severity >= 0 && severity <= SCAN_SEVERITIES.indexOf(threshold); + return severity >= 0 && severity <= thresholdRank; +} + +export function severityThresholdRank(threshold: SeverityLevel): number { + const rank = SCAN_SEVERITIES.indexOf(threshold); + if (rank < 0) { + throw new ConfigurationError( + `Unknown severity threshold: ${String(threshold)}.`, + ); + } + return rank; } diff --git a/sdk/typescript/src/targets.ts b/sdk/typescript/src/targets.ts index 41c17b1e0..96e76d960 100644 --- a/sdk/typescript/src/targets.ts +++ b/sdk/typescript/src/targets.ts @@ -7,8 +7,9 @@ import { promisify } from "node:util"; import { InvalidTargetError } from "./errors.js"; import { resolveTrustedExecutable } from "./trusted-executable.js"; import { windowsUnsafePathComponent } from "./windows-path.js"; -import type { ScanMode } from "./scan-settings.js"; -export type { ScanMode } from "./scan-settings.js"; + +import type { ScanMode } from "./scan-modes.js"; +export type { ScanMode } from "./scan-modes.js"; const execFile = promisify(execFileCallback); const UNSUPPORTED_GIT_ENVIRONMENT = new Set([ diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 077aea3d1..fbfea40a4 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -69,6 +69,7 @@ import { } from "./support/api-events.js"; import { runTestInSubprocess } from "./support/test-subprocess.js"; import { FindingWorkflow } from "../src/finding-workflow.js"; +import { DEFAULT_DEEP_SCAN_SETTINGS } from "../src/deep-scan-defaults.js"; type ScanObserverName = Parameters< NonNullable @@ -206,6 +207,60 @@ test.each(["completed", "receipt-lost", "scan-interrupted", "prompt-files"])( }, ); +test.each(["ambient settings", "shipped defaults"])( + "a deep workflow can resume after changing %s but rejects a different request", + async (change) => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const codexHome = join(root, "codex-home"); + const source = join(codexHome, "codex-security", "config.toml"); + await mkdir(repository); + await mkdir(dirname(source), { recursive: true }); + await writeFile(source, "[deep_scan]\nsubagents = 1\n"); + const environment = { + PATH: process.env["PATH"], + SystemRoot: process.env["SystemRoot"], + CODEX_HOME: codexHome, + CODEX_SECURITY_STATE_DIR: join(root, "state"), + }; + const client = new TestClient( + {}, + { + environment, + runWorkbench: async (options, args, input) => { + if ( + args[0] === "finding-workflow" && + JSON.parse(input!).action === "begin" + ) { + throw new Error("Synthetic stop before scan"); + } + return runWorkbench(options, args, input); + }, + }, + ); + const defaults = DEFAULT_DEEP_SCAN_SETTINGS as { workers: number }; + const originalWorkers = defaults.workers; + const request = { workflowId: "deep-resume", mode: "deep" } as const; + try { + await expect(client.run(repository, request)).rejects.toThrow( + "Synthetic stop before scan", + ); + if (change === "ambient settings") + await writeFile(source, "[deep_scan]\nworkers = 9\nsubagents = 2\n"); + else defaults.workers = originalWorkers + 1; + await expect(client.run(repository, request)).rejects.toThrow( + "Synthetic stop before scan", + ); + await expect( + client.run(repository, { ...request, workers: 2 }), + ).rejects.toThrow("already bound to a different"); + } finally { + defaults.workers = originalWorkers; + await client.close(); + } + }, +); + const EXTERNAL_PROVIDER_CASES = [ [ "OpenRouter", diff --git a/sdk/typescript/tests-ts/cli-project-config.test.ts b/sdk/typescript/tests-ts/cli-project-config.test.ts index ebc3311a0..00f4bf9f9 100644 --- a/sdk/typescript/tests-ts/cli-project-config.test.ts +++ b/sdk/typescript/tests-ts/cli-project-config.test.ts @@ -1,4 +1,12 @@ -import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { execFileSync } from "node:child_process"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, test } from "bun:test"; @@ -6,7 +14,13 @@ import { CodexSecurity, type ScanOptions } from "../src/api.js"; import { main } from "../src/cli.js"; import type { CodexSecurityConfig, JsonObject } from "../src/config.js"; import type { ProjectConfigInput } from "../src/project-config-schema.js"; -import { capture, dependencies, fakeResult } from "./cli-fixtures.js"; +import { readProjectConfig } from "../src/project-config.js"; +import { + capture, + dependencies, + fakePreflight, + fakeResult, +} from "./cli-fixtures.js"; const directories: string[] = []; afterEach(async () => { @@ -39,6 +53,431 @@ async function fixture(input: ProjectConfigInput | string) { return { root, repository, configDirectory, config }; } +test.each([undefined, "starter.json"])( + "init writes a valid unpinned starter and refuses overwrites: %s", + async (file) => { + const input = await fixture({}); + const args = ["init", ...(file === undefined ? [] : [file]), "--json"]; + const output = capture(); + const deps = dependencies({ + currentDirectory: input.root, + onConfig: () => { + throw new Error("No runtime for init"); + }, + }); + expect(await main(args, output.stream, capture().stream, deps)).toBe(0); + const path = join(input.root, file ?? "codex-security.yaml"); + expect(JSON.parse(output.text())).toEqual({ path }); + const selected = await readProjectConfig(path); + expect(selected.input).toEqual({ + $schema: + "./node_modules/@openai/codex-security/schemas/project-config.schema.json", + }); + const contents = await readFile(path, "utf8"); + expect(await main(args, capture().stream, capture().stream, deps)).toBe(2); + expect(await readFile(path, "utf8")).toBe(contents); + }, +); + +test("info resolves a config and its sources without a target, prompt reads, or runtime", async () => { + const input = await fixture({ + scan: { + mode: "deep", + instructions_file: "not-read.md", + deep: { workers: 2 }, + }, + codex: { + model: "gpt-5.6-terra", + synthetic_private_setting: "synthetic-private-value", + }, + }); + const home = join(input.root, "home"); + await mkdir(join(home, "codex-security"), { recursive: true }); + await writeFile( + join(home, "codex-security", "config.toml"), + "[deep_scan]\nsubagents = 1\n", + ); + const stdout = capture(); + expect( + await main( + ["info", "-c", input.config, "--json"], + stdout.stream, + capture().stream, + dependencies({ + currentDirectory: input.configDirectory, + environment: { CODEX_HOME: home }, + onConfig: () => { + throw new Error("No runtime for info"); + }, + }), + ), + ).toBe(0); + expect(stdout.text()).not.toContain("synthetic-private-value"); + expect(JSON.parse(stdout.text())).toMatchObject({ + model: "gpt-5.6-terra", + configuration: { + path: input.config, + settings: { + mode: "deep", + workers: 2, + subagents: 1, + scanPromptFile: join(input.configDirectory, "not-read.md"), + }, + sources: { + "scan.deep.workers": "project", + "scan.deep.subagents_per_worker": "legacy", + "scan.deep.max_time_hours": "default", + "policy.fail_on_severity": "default", + }, + }, + }); +}); + +test("info without a file reports default sources, including unset settings", async () => { + const input = await fixture({}); + const stdout = capture(); + expect( + await main( + ["info", "--json", "--filter-output", "configuration"], + stdout.stream, + capture().stream, + dependencies({ currentDirectory: input.root }), + ), + ).toBe(0); + expect(JSON.parse(stdout.text())).toMatchObject({ + configuration: { + sources: { + auth: "default", + "scan.mode": "default", + "scan.scope": "default", + "scan.knowledge_base": "default", + "scan.instructions_file": "default", + "scan.validation_file": "default", + "output.directory": "default", + "policy.fail_on_severity": "default", + "limits.max_cost_usd_per_scan": "default", + }, + }, + }); +}); + +test("an explicit config flag overrides the operator-selected environment file", async () => { + const input = await fixture({ codex: { model: "gpt-5.6-terra" } }); + const override = join(input.root, "override.json"); + await writeFile( + override, + JSON.stringify({ codex: { model: "gpt-5.6-sol" } }), + ); + for (const [flags, expected] of [ + [[], "gpt-5.6-terra"], + [["-c", override], "gpt-5.6-sol"], + ] as const) { + let selected: CodexSecurityConfig | undefined; + expect( + await main( + ["scan", ...flags, "--json"], + capture().stream, + capture().stream, + dependencies({ + currentDirectory: input.repository, + environment: { CODEX_SECURITY_PROJECT_CONFIG: input.config }, + onConfig: (config) => { + selected = config; + }, + }), + ), + ).toBe(0); + expect(selected?.codexOverrides?.["model"]).toBe(expected); + } +}); + +test("a missing operator-selected environment file fails without falling back", async () => { + const input = await fixture({}); + let initialized = false; + expect( + await main( + ["scan", "--json"], + capture().stream, + capture().stream, + dependencies({ + currentDirectory: input.repository, + environment: { + CODEX_SECURITY_PROJECT_CONFIG: join(input.root, "missing.yaml"), + }, + onConfig: () => { + initialized = true; + }, + }), + ), + ).toBe(2); + expect(initialized).toBe(false); +}); + +test.each([123, "", " "])( + "a selected profile's invalid model is reported at the provider flag: %j", + async (model) => { + const input = await fixture({ + codex: { profile: "selected", profiles: { selected: { model } } }, + }); + let initialized = false; + const stderr = capture(); + expect( + await main( + ["scan", "-c", input.config, "--provider", "amazon-bedrock", "--json"], + capture().stream, + stderr.stream, + dependencies({ + currentDirectory: input.repository, + onConfig: () => { + initialized = true; + }, + }), + ), + ).toBe(2); + expect(stderr.text()).toContain( + "--model must be a nonempty string when using --provider amazon-bedrock", + ); + expect(initialized).toBe(false); + }, +); + +test("rerun rejects a blank replacement when scan instructions are required", async () => { + const input = await fixture({}); + const prompt = join(input.root, "empty.md"); + await writeFile(prompt, " \n"); + const stderr = capture(); + let initialized = false; + expect( + await main( + ["scans", "rerun", "saved", "--scan-prompt-file", prompt, "--json"], + capture().stream, + stderr.stream, + dependencies({ + currentDirectory: input.root, + onWorkbench: async () => ({ + recipe: { + repository: input.repository, + target: { kind: "repository", paths: [] }, + mode: "standard", + config: {}, + requiresScanPrompt: true, + }, + }), + onConfig: () => { + initialized = true; + }, + }), + ), + ).toBe(2); + expect(stderr.text()).toContain("--scan-prompt-file must not be empty"); + expect(initialized).toBe(false); +}); + +test("bulk scans apply config per CSV mode, preserve scope overrides, and retain the policy on resume", async () => { + const config: ProjectConfigInput = { + auth: "api-key", + scan: { + scope: { paths: ["src"] }, + knowledge_base: ["context.md"], + instructions_file: "instructions.md", + deep: { workers: 2, subagents_per_worker: 0 }, + }, + codex: { model: "gpt-5.6-terra" }, + limits: { max_cost_usd_per_scan: 5 }, + policy: { fail_on_severity: "high" }, + output: { directory: "../batch-results" }, + }; + const input = await fixture(config); + await writeFile( + join(input.configDirectory, "context.md"), + "Synthetic context.", + ); + await writeFile( + join(input.configDirectory, "instructions.md"), + "Review synthetic boundaries.", + ); + await writeFile( + join(input.repository, "src", "index.ts"), + "export const value = 1;\n", + ); + await writeFile( + join(input.repository, "lib", "index.ts"), + "export const value = 2;\n", + ); + for (const args of [ + ["init", "-q", input.repository], + ["-C", input.repository, "add", "."], + [ + "-C", + input.repository, + "-c", + "user.name=Test", + "-c", + "user.email=test@example.test", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "fixture", + ], + ]) + execFileSync("git", args); + const revision = execFileSync( + "git", + ["-C", input.repository, "rev-parse", "HEAD"], + { encoding: "utf8" }, + ).trim(); + const csv = join(input.root, "repositories.csv"); + await writeFile( + csv, + `id,repository,revision,mode,scope\nstandard,${input.repository},${revision},standard,lib\ndeep,${input.repository},${revision},deep,\n`, + ); + const selected: ScanOptions[] = []; + const deps = dependencies({ currentDirectory: input.root }); + deps.createSecurity = (native) => { + expect(native.codexOverrides?.["model"]).toBe("gpt-5.6-terra"); + return { + preflight: async () => fakePreflight(), + close: async () => {}, + run: async (_repository, options = {}) => { + selected.push(options); + const result = fakeResult(["high"]); + await mkdir(options.outputDir!, { recursive: true }); + for (const [name, content] of Object.entries({ + "scan-manifest.json": result.manifest, + "findings.json": result.findings, + "coverage.json": result.coverage, + "report.md": "Synthetic report.", + })) + await writeFile( + join(options.outputDir!, name), + typeof content === "string" ? content : JSON.stringify(content), + ); + return result; + }, + }; + }; + const args = [ + "bulk-scan", + csv, + "-c", + input.config, + "--max-cost", + "3", + "--json", + ]; + const output = capture(); + expect(await main(args, output.stream, capture().stream, deps)).toBe(1); + expect(JSON.parse(output.text())).toMatchObject({ + completed: 2, + failed: 0, + policyFailed: true, + }); + expect(selected.find((options) => options.mode === "standard")).toMatchObject( + { + target: ["lib"], + auth: "api-key", + maxCostUsd: 3, + failureSeverity: "high", + knowledgeBasePaths: [join(input.configDirectory, "context.md")], + scanPrompt: "Review synthetic boundaries.", + }, + ); + expect( + selected.find((options) => options.mode === "standard")?.workers, + ).toBeUndefined(); + expect(selected.find((options) => options.mode === "deep")).toMatchObject({ + target: ["src"], + workers: 2, + subagents: 0, + }); + const resumed = capture(); + expect(await main(args, resumed.stream, capture().stream, deps)).toBe(1); + expect(JSON.parse(resumed.text())).toMatchObject({ + skipped: 2, + policyFailed: true, + }); + expect(selected).toHaveLength(2); + await writeFile( + input.config, + JSON.stringify({ ...config, policy: { fail_on_severity: "low" } }), + ); + const stderr = capture(); + expect(await main(args, capture().stream, stderr.stream, deps)).toBe(2); + expect(stderr.text()).toContain("manifest does not match"); + expect(selected).toHaveLength(2); +}); + +test.each(["standard", "deep"] as const)( + "component scans honor shared %s settings and severity policy", + async (mode) => { + const input = await fixture({ + scan: { + mode, + scope: { paths: ["src"] }, + instructions_file: "instructions.md", + ...(mode === "standard" ? { validation_file: "validation.md" } : {}), + deep: { workers: 2, subagents_per_worker: 0 }, + }, + codex: { model: "gpt-5.6-terra" }, + policy: { fail_on_severity: "high" }, + output: { directory: "../component-results" }, + }); + await writeFile( + join(input.repository, "lib", "index.ts"), + "export const value = 1;\n", + ); + await writeFile( + join(input.configDirectory, "instructions.md"), + "Review synthetic boundaries.", + ); + await writeFile( + join(input.configDirectory, "validation.md"), + "Validate synthetic boundaries.", + ); + let selected: ScanOptions | undefined; + const stdout = capture(); + expect( + await main( + [ + "scan-components", + input.repository, + "-c", + input.config, + "--component", + "lib", + "--headless", + "--json", + ], + stdout.stream, + capture().stream, + dependencies({ + currentDirectory: input.root, + result: fakeResult(["high"]), + onTurn: (_repository, options) => { + selected = options as ScanOptions; + }, + }), + ), + ).toBe(1); + expect(selected).toMatchObject({ + mode, + target: ["lib"], + scanPrompt: "Review synthetic boundaries.", + failureSeverity: "high", + }); + if (mode === "deep") + expect(selected).toMatchObject({ workers: 2, subagents: 0 }); + else + expect(selected?.validationPrompt).toBe("Validate synthetic boundaries."); + expect(JSON.parse(stdout.text())).toMatchObject({ + completed: 1, + failed: 0, + policyFailed: true, + }); + }, +); + test("actual CLI parsing preserves file values when flags are absent", async () => { const input = await fixture({ auth: "api-key", @@ -174,6 +613,56 @@ test("CLI values override matching file values, including native objects and lis }); }); +test("rerun accepts replacement scan and validation files relative to the invocation directory", async () => { + const input = await fixture({}); + await writeFile( + join(input.configDirectory, "instructions.md"), + "Review the synthetic boundary.", + ); + await writeFile( + join(input.configDirectory, "validation.md"), + "Validate the synthetic boundary.", + ); + const recipe: JsonObject = { + repository: input.repository, + target: { kind: "repository", paths: [] }, + mode: "standard", + config: {}, + requiresScanPrompt: true, + validationMode: "custom", + }; + let selected: ScanOptions | undefined; + const stderr = capture(); + expect( + await main( + [ + "scans", + "rerun", + "saved", + "--scan-prompt-file", + "instructions.md", + "--validation-prompt-file", + "validation.md", + "--json", + ], + capture().stream, + stderr.stream, + dependencies({ + currentDirectory: input.configDirectory, + onWorkbench: async () => ({ recipe }), + onTurn: (_target, options) => { + selected = options as ScanOptions; + }, + }), + ), + ).toBe(0); + expect(selected).toMatchObject({ + scanPrompt: "Review the synthetic boundary.", + validationPrompt: "Validate the synthetic boundary.", + parentScanId: "saved", + }); +}); + test.each([ [ { paths: ["src"] }, @@ -223,7 +712,7 @@ test.each([ [["--path", "src", "--diff", "HEAD"], "mutually exclusive"], [["--head", "HEAD"], "--head requires --diff"], [["--base", "HEAD"], "--base requires --working-tree"], - [["--workers", "2", "--mode", "standard"], "require --mode deep"], + [["--workers", "2", "--mode", "standard"], "require deep mode"], [ ["--model", "gpt-5.6-terra", "--codex", 'model="gpt-5.6-sol"'], "--model conflicts", @@ -656,7 +1145,10 @@ test("rerun restores all saved deep settings and authentication without loading capture().stream, dependencies({ currentDirectory: input.repository, - environment: { OPENAI_API_KEY: "synthetic-test-key" }, + environment: { + OPENAI_API_KEY: "synthetic-test-key", + CODEX_SECURITY_PROJECT_CONFIG: input.config, + }, onWorkbench: async () => ({ recipe }), onTurn: (_target, value) => { selected = value as ScanOptions; diff --git a/sdk/typescript/tests-ts/cli.test.ts b/sdk/typescript/tests-ts/cli.test.ts index ef277858c..dbab706ef 100644 --- a/sdk/typescript/tests-ts/cli.test.ts +++ b/sdk/typescript/tests-ts/cli.test.ts @@ -826,12 +826,12 @@ describe("CLI", () => { process.env["HOME"] = home; process.env["USERPROFILE"] = home; - expect(resolveCliPath(currentDirectory, "~/repositories.csv")).toBe( - join(home, "repositories.csv"), - ); - expect(resolveCliPath(currentDirectory, "~person/repositories.csv")).toBe( - join(currentDirectory, "~person", "repositories.csv"), - ); + expect( + resolveCliPath(currentDirectory, "~/repositories.csv"), + ).toBe(join(home, "repositories.csv")); + expect( + resolveCliPath(currentDirectory, "~person/repositories.csv"), + ).toBe(join(currentDirectory, "~person", "repositories.csv")); const stdout = capture(); expect( @@ -2725,37 +2725,34 @@ 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", ".", "--workers", "2"], "Deep scan settings require deep mode"], [ ["scan", ".", "--max-time-hours", "1.5"], - "Deep scan settings require --mode deep", + "Deep scan settings require deep mode", ], [ ["scan", ".", "--mode", "deep", "--workers", "0"], - "expected number to be >0", + "must be a positive integer", ], [ ["scan", ".", "--mode", "deep", "--subagents", "-1"], - "expected number to be >=0", + "must be a non-negative integer", ], [ ["scan", ".", "--mode", "deep", "--stop-after-no-new", "0"], - "expected number to be >0", + "must be a positive integer", ], [ ["scan", ".", "--mode", "deep", "--max-discovery-runs", "0"], - "expected number to be >0", + "must be a positive integer", ], [ ["scan", ".", "--mode", "deep", "--max-time-hours", "0"], - "expected number to be >0", + "must be a positive number no greater than 96", ], [ ["scan", ".", "--mode", "deep", "--max-time-hours", "96.5"], - "expected number to be <=96", + "must be a positive number no greater than 96", ], [["scan", ".", "--path="], "--path must not be empty"], [ diff --git a/sdk/typescript/tests-ts/deep-config.test.ts b/sdk/typescript/tests-ts/deep-config.test.ts index 1d825efb3..ad39e922c 100644 --- a/sdk/typescript/tests-ts/deep-config.test.ts +++ b/sdk/typescript/tests-ts/deep-config.test.ts @@ -102,6 +102,51 @@ test("validates legacy values after matching explicit overrides", async () => { ).toBe(2); }); +test("reports the TOML key and source file for an invalid ambient value", async () => { + const input = await fixture("[deep_scan]\nstop_after_no_new = 0\n"); + await expect(resolveDeepScanConfig({}, input.source)).rejects.toThrow( + `Deep scan stop_after_no_new in ${input.source} must be a positive integer`, + ); +}); + +test.each(["same path", "directory link"])( + "updating ambient overrides through the %s does not pin inherited defaults", + async (kind) => { + const input = await fixture( + "[deep_scan]\nworkers = 7\n[other]\nkeep = true\n", + ); + let destination = input.source; + if (kind === "directory link") { + const link = join(input.root, "linked"); + await symlink(dirname(input.source), link, "junction"); + destination = join(link, "config.toml"); + } + await writeDeepScanConfig( + destination, + await resolveDeepScanConfig({ workers: 8 }, input.source), + ); + expect(parseToml(await readFile(input.source, "utf8"))).toEqual({ + deep_scan: { workers: 8 }, + other: { keep: true }, + }); + }, +); + +test("does not write a snapshot when source path resolution fails", async () => { + const input = await fixture(); + const resolved = await resolveDeepScanConfig({}, input.source); + const loop = join(input.root, "loop"); + await symlink(loop, loop, "junction"); + const destination = join(input.root, "runtime", "config.toml"); + await expect( + writeDeepScanConfig(destination, { + ...resolved, + source: join(loop, "config.toml"), + }), + ).rejects.toThrow(); + await expect(readFile(destination)).rejects.toMatchObject({ code: "ENOENT" }); +}); + test.each([ ["deep_scan = []\n", "TOML table"], ["deep_scan = 2026-01-01\n", "TOML table"], @@ -184,6 +229,26 @@ test("complete settings do not overwrite an invalid ambient destination", async expect(await readFile(input.source, "utf8")).toBe(contents); }); +test("a complete snapshot replaces stale deep keys but preserves other ambient sections", async () => { + const input = await fixture( + "[deep_scan]\nobsolete_setting = true\n[other]\nkeep = true\n", + ); + await writeDeepScanConfig( + input.source, + await resolveDeepScanConfig(DEFAULT_DEEP_SCAN_SETTINGS, input.source), + ); + const document = parseToml(await readFile(input.source, "utf8")); + expect(document["other"]).toEqual({ keep: true }); + expect(document["deep_scan"]).toEqual({ + workers: 4, + subagents: 3, + stop_after_no_new: 4, + stop_after_consecutive_errors: 3, + max_discovery_runs: 40, + max_time_hours: 96, + }); +}); + test("runtime preparation writes the snapshot even if the ambient file changes", async () => { const input = await fixture( "[deep_scan]\nworkers = 7\nstop_after_no_new = 6\n[other]\nenabled = true\n", diff --git a/sdk/typescript/tests-ts/project-config.test.ts b/sdk/typescript/tests-ts/project-config.test.ts index d10433360..575de53d3 100644 --- a/sdk/typescript/tests-ts/project-config.test.ts +++ b/sdk/typescript/tests-ts/project-config.test.ts @@ -187,28 +187,66 @@ describe("project configuration input contract", () => { expect((await readProjectConfig(json)).input).toEqual(input); }); - test("loads YAML profiles that reuse an anchored table many times", async () => { + test.each([ + [150, 1], + [500, 20], + ])( + "loads %i YAML profiles reusing a %i-field table", + async (count, fields) => { + const root = await temporaryDirectory(); + const path = join(root, "profiles.yaml"); + const profile = { + model: "synthetic-model", + ...Object.fromEntries( + Array.from({ length: fields - 1 }, (_, index) => [ + `setting_${index}`, + index, + ]), + ), + }; + const names = Array.from( + { length: count }, + (_, index) => `profile_${index}`, + ); + await writeFile( + path, + [ + "codex:", + " profiles:", + ` shared: &shared ${JSON.stringify(profile)}`, + ...names.map((name) => ` ${name}: *shared`), + ].join("\n"), + ); + const project = await readProjectConfig(path); + expect( + resolveScanSettings(project, {}, root).config.codexOverrides[ + "profiles" + ], + ).toEqual({ + shared: profile, + ...Object.fromEntries(names.map((name) => [name, profile])), + }); + }, + ); + + test("rejects excessive nested YAML alias expansion before resolving settings", async () => { const root = await temporaryDirectory(); - const path = join(root, "profiles.yaml"); - const profile = { model: "synthetic-model" }; - const names = Array.from({ length: 150 }, (_, index) => `profile_${index}`); + const path = join(root, "nested-aliases.yaml"); await writeFile( path, [ "codex:", - " profiles:", - " shared: &shared", - " model: synthetic-model", - ...names.map((name) => ` ${name}: *shared`), + " shared_0: &shared_0 [value, value, value, value, value]", + ...Array.from( + { length: 6 }, + (_, index) => + ` shared_${index + 1}: &shared_${index + 1} [${Array(10).fill(`*shared_${index}`).join(", ")}]`, + ), ].join("\n"), ); - const project = await readProjectConfig(path); - expect( - resolveScanSettings(project, {}, root).config.codexOverrides["profiles"], - ).toEqual({ - shared: profile, - ...Object.fromEntries(names.map((name) => [name, profile])), - }); + await expect(readProjectConfig(path)).rejects.toThrow( + "Cannot parse project configuration", + ); }); test.each([ @@ -342,7 +380,7 @@ describe("project configuration resolution", () => { ).toBeUndefined(); expect(() => resolveScanSettings(project, { mode: "standard", workers: 2 }, root), - ).toThrow("require --mode deep"); + ).toThrow("require deep mode"); }); test("keeps existing unsafe native-key protections before merging", async () => { diff --git a/sdk/typescript/tests-ts/result.test.ts b/sdk/typescript/tests-ts/result.test.ts index 81b0970cb..935459e68 100644 --- a/sdk/typescript/tests-ts/result.test.ts +++ b/sdk/typescript/tests-ts/result.test.ts @@ -9,6 +9,7 @@ import type { FindingsDocument, RepositoryFinding, ScanManifest, + SeverityLevel, } from "../src/index.js"; const manifest = { @@ -51,6 +52,14 @@ const coverage = { } satisfies CoverageDocument; describe("ScanResult", () => { + test.each([{ levels: [] }, { levels: ["high"] }] satisfies { + levels: SeverityLevel[]; + }[])("rejects an unknown threshold with findings %j", ({ levels }) => { + expect(() => + fakeResult([...levels]).hasFindingsAtOrAbove("hihg" as SeverityLevel), + ).toThrow("Unknown severity threshold"); + }); + test("evaluates a severity threshold without filtering findings or changing serialization", () => { const result = fakeResult(["medium", "informational"]); const serialized = result.toJSON(); diff --git a/sdk/typescript/tests-ts/sdk-project-config.test.ts b/sdk/typescript/tests-ts/sdk-project-config.test.ts index cdc095cca..63421e482 100644 --- a/sdk/typescript/tests-ts/sdk-project-config.test.ts +++ b/sdk/typescript/tests-ts/sdk-project-config.test.ts @@ -106,7 +106,7 @@ test.each(["standard", "deep"] as const)( } for (const configuration of configurations) { expect(configuration.config).toEqual({ codexOverrides: input.codex }); - expect(configuration.options).toEqual({ + expect(configuration.options).toEqual({ ...options, validationPromptFile: undefined, }); diff --git a/sdk/typescript/tests-ts/skeleton.test.ts b/sdk/typescript/tests-ts/skeleton.test.ts index ba145aa56..f256e69cd 100644 --- a/sdk/typescript/tests-ts/skeleton.test.ts +++ b/sdk/typescript/tests-ts/skeleton.test.ts @@ -358,6 +358,10 @@ describe("TypeScript package skeleton", () => { ); expect(packageJson.scripts.build).not.toMatch(/\b(?:pnpm|npm|bun)\b/u); + expect(packageJson.scripts.build).toMatch(/^node --run clean &&/u); + expect(packageJson.scripts.build).toContain( + "node scripts/build-dashboard.mjs", + ); expect(packageJson.scripts["build:plugin"]).toBe( "node scripts/build-plugin.mjs", ); From ce186091afda7677a2e177ed134ee03acdd4d961 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 31 Aug 2026 01:59:57 -0700 Subject: [PATCH 11/12] fix(config): finish bulk inputs and portable starter checks Use the actual scan context for prompt protections, reject unsupported bulk scopes before starting work, and make nested starter schema hints relative. Keep path fixtures native on Windows and compare macOS temporary aliases canonically in the installed-package smoke check. --- docs/project-configuration.md | 8 +++- sdk/typescript/README.md | 7 +++- sdk/typescript/scripts/smoke-package.mjs | 19 +++++++++- sdk/typescript/src/cli.ts | 9 +++-- sdk/typescript/src/multiscan.ts | 13 ++++++- sdk/typescript/src/project-config.ts | 30 +++++++++++++-- sdk/typescript/src/prompt-files.ts | 28 ++++++++------ .../tests-ts/cli-project-config.test.ts | 28 ++++++++++---- sdk/typescript/tests-ts/cli-workbench.test.ts | 5 ++- sdk/typescript/tests-ts/cli.test.ts | 11 +++--- sdk/typescript/tests-ts/multiscan.test.ts | 37 +++++++++++++++++++ 11 files changed, 156 insertions(+), 39 deletions(-) diff --git a/docs/project-configuration.md b/docs/project-configuration.md index 2025b74f7..81fa26b8d 100644 --- a/docs/project-configuration.md +++ b/docs/project-configuration.md @@ -32,7 +32,8 @@ codex-security info -c codex-security.yaml --json `init [file]` defaults to `codex-security.yaml`, refuses to overwrite an existing file, and accepts `.yaml`, `.yml`, or `.json`. YAML starters show current defaults as comments so future releases can still update defaults you have not overridden. -The editor hint expects the package to be installed locally in `node_modules`. +The editor hint is relative to the chosen file and expects the package to be +installed in the invocation directory's `node_modules`. For a project with a `src` directory: @@ -262,7 +263,10 @@ Both commands use the same config precedence, path anchoring, native settings, context, prompts, per-attempt cost limit, and severity policy as `scan`. A bulk CSV row's mode and scope override the file's defaults for that repository. -Deep settings apply only to deep rows. Component plans select each component's +Bulk checkouts are clean, shallow snapshots, so bulk scans accept repository or +path scopes. A configured diff or working-tree scope is rejected before work +starts unless every affected row supplies its own CSV path scope. Deep settings +apply only to deep rows. Component plans select each component's scope, overriding `scan.scope`; `scan.mode` selects standard or deep component scans. Batch `--workers` controls concurrent repositories or components, while `scan.deep.workers` controls discovery workers within each deep scan. diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 7efdff7f1..12bf2762b 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -334,7 +334,8 @@ CI scanner configuration outside the checkout being assessed. `init [file]` writes `codex-security.yaml` by default and never overwrites an existing file. YAML starters show defaults as comments; JSON starters contain the -editor schema hint. `info` reports effective model details and native key sources +editor schema hint, relative to the chosen file and the invocation directory's +local package installation. `info` reports effective model details and native key sources without dumping raw native values. ```yaml @@ -380,6 +381,10 @@ remain unchanged. and `--working-tree` scans staged and unstaged changes. Deep scans support repository and path targets. +Bulk scans use clean, shallow checkouts and support repository or path scopes. +They reject configured diff or working-tree scopes before starting unless each +affected CSV row supplies its own path scope. + Working-tree snapshots include files from untracked nested Git repositories. Initialized submodules must be clean and checked out at the commit recorded by the parent repository. diff --git a/sdk/typescript/scripts/smoke-package.mjs b/sdk/typescript/scripts/smoke-package.mjs index ba63ad62e..f855e8e9a 100644 --- a/sdk/typescript/scripts/smoke-package.mjs +++ b/sdk/typescript/scripts/smoke-package.mjs @@ -7,6 +7,7 @@ import { mkdtemp, readFile, readdir, + realpath, rm, stat, writeFile, @@ -492,7 +493,8 @@ try { assert.match(help, /\bpublish\b/u); assert.match(help, /\bdedupe\b/u); - const starterPath = join(consumer, "codex-security.yaml"); + // The CLI's working directory is canonical even when tmpdir() is an alias. + const starterPath = join(await realpath(consumer), "codex-security.yaml"); const starter = JSON.parse( run(process.execPath, [launcher, "init", "--json"], { cwd: consumer, @@ -513,6 +515,21 @@ try { assert.equal(info.configuration.sources["scan.mode"], "default"); } + const nestedDirectory = join(consumer, "settings"); + await mkdir(nestedDirectory); + const nestedPath = join(nestedDirectory, "security.json"); + run(process.execPath, [launcher, "init", nestedPath, "--json"], { + cwd: consumer, + capture: true, + }); + const nestedConfig = JSON.parse(await readFile(nestedPath, "utf8")); + assert.equal( + await realpath(resolve(nestedDirectory, nestedConfig.$schema)), + await realpath( + join(installedRoot, "schemas", "project-config.schema.json"), + ), + ); + const publicationScan = join(consumer, "publication-scan"); await cp( join(installedRoot, "_bundled_plugin", "examples", "completed-scan"), diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 346c570dd..a3aaa7a10 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -3533,9 +3533,11 @@ export async function main( currentDirectory, ); const settings = resolved.options; + // These shared operator inputs precede the CSV repository checkouts; + // the invocation directory is not a scan-repository boundary. const prompts = await resolveScanPrompts( settings, - currentDirectory, + undefined, currentDirectory, ); // A CSV row may choose a different mode; resolve the same file for both @@ -4396,11 +4398,12 @@ export async function main( output: z.object({ path: z.string() }).optional(), async run({ args }) { try { + const directory = dependencies.currentDirectory(); const path = resolveCliPath( - dependencies.currentDirectory(), + directory, args.file ?? "codex-security.yaml", ); - await writeFile(path, projectConfigStarter(path), { + await writeFile(path, projectConfigStarter(path, directory), { flag: "wx", mode: 0o600, }); diff --git a/sdk/typescript/src/multiscan.ts b/sdk/typescript/src/multiscan.ts index 740e9edbd..d8fab1532 100644 --- a/sdk/typescript/src/multiscan.ts +++ b/sdk/typescript/src/multiscan.ts @@ -24,7 +24,7 @@ import type { ScanCost } from "./cost.js"; import { safeErrorMessage, ScanCostLimitExceededError } from "./errors.js"; import type { CoverageDocument } from "./models.js"; import { requireSecureOutputAncestry } from "./runtime.js"; -import type { ScanMode } from "./targets.js"; +import { DiffTarget, type ScanMode } from "./targets.js"; import type { ScanSettings } from "./scan-settings.js"; import { workflowDigest } from "./finding-workflow.js"; import { resolveTrustedExecutable } from "./trusted-executable.js"; @@ -117,6 +117,17 @@ export async function runMultiscan( dirname(resolve(options.inputPath)), options.mode, ); + if ( + tasks.some( + (task) => + task.scope === undefined && + options.scanOptionsByMode?.[task.mode]?.target instanceof DiffTarget, + ) + ) { + throw new Error( + "Bulk scans do not support diff or working-tree scopes because their checkouts are clean, shallow snapshots. Use repository or path scopes instead.", + ); + } if ( options.validationPrompt !== undefined && tasks.some((task) => task.mode === "deep") diff --git a/sdk/typescript/src/project-config.ts b/sdk/typescript/src/project-config.ts index ad950a293..e853bcbee 100644 --- a/sdk/typescript/src/project-config.ts +++ b/sdk/typescript/src/project-config.ts @@ -1,5 +1,13 @@ import { readFile } from "node:fs/promises"; -import { dirname, extname, resolve } from "node:path"; +import { + dirname, + extname, + isAbsolute, + relative, + resolve, + sep, +} from "node:path"; +import { pathToFileURL } from "node:url"; import Ajv from "ajv"; import { parseDocument } from "yaml"; import { @@ -165,9 +173,23 @@ function projectConfigExtension(path: string): string { return extension; } -export function projectConfigStarter(path: string): string { - const schema = - "./node_modules/@openai/codex-security/schemas/project-config.schema.json"; +export function projectConfigStarter( + path: string, + directory = process.cwd(), +): string { + const schemaPath = resolve( + directory, + "node_modules/@openai/codex-security/schemas/project-config.schema.json", + ); + const relativeSchema = relative( + dirname(resolve(directory, path)), + schemaPath, + ); + const schema = isAbsolute(relativeSchema) + ? pathToFileURL(schemaPath).href + : `${relativeSchema.startsWith(".") ? "" : "./"}${relativeSchema + .split(sep) + .join("/")}`; if (projectConfigExtension(path) === ".json") return `${JSON.stringify({ $schema: schema }, null, 2)}\n`; return [ diff --git a/sdk/typescript/src/prompt-files.ts b/sdk/typescript/src/prompt-files.ts index 4e96405e0..831cca032 100644 --- a/sdk/typescript/src/prompt-files.ts +++ b/sdk/typescript/src/prompt-files.ts @@ -25,7 +25,7 @@ type ResolvedScanPrompts = Pick< /** Resolve selected files once; an inline SDK prompt overrides its file. */ export async function resolveScanPrompts( options: ScanPromptSettings, - repository: string, + repository: string | undefined, directory = process.cwd(), ): Promise { const read = async (inline: string | undefined, file: string | undefined) => @@ -67,25 +67,29 @@ export async function resolveScanPrompts( export async function readRegularInputFile( path: string, - repository: string, + repository: string | undefined, metadata?: Pick, ): Promise { const selected = metadata ?? (await lstat(path, { bigint: true })); if (!selected.isFile()) { throw new CodexSecurityError("Input files must be regular files."); } - const canonicalRepository = await realpath(repository); const canonicalParent = await realpath(dirname(path)); - if (isOutsidePath(relative(canonicalRepository, canonicalParent))) { - for (let ancestor = dirname(path); ; ancestor = dirname(ancestor)) { - if ( - !isOutsidePath(relative(canonicalRepository, await realpath(ancestor))) - ) { - throw new CodexSecurityError( - "Input files must not follow repository directory links outside the selected repository.", - ); + if (repository !== undefined) { + const canonicalRepository = await realpath(repository); + if (isOutsidePath(relative(canonicalRepository, canonicalParent))) { + for (let ancestor = dirname(path); ; ancestor = dirname(ancestor)) { + if ( + !isOutsidePath( + relative(canonicalRepository, await realpath(ancestor)), + ) + ) { + throw new CodexSecurityError( + "Input files must not follow repository directory links outside the selected repository.", + ); + } + if (dirname(ancestor) === ancestor) break; } - if (dirname(ancestor) === ancestor) break; } } const file = await open( diff --git a/sdk/typescript/tests-ts/cli-project-config.test.ts b/sdk/typescript/tests-ts/cli-project-config.test.ts index 00f4bf9f9..4aa23f28f 100644 --- a/sdk/typescript/tests-ts/cli-project-config.test.ts +++ b/sdk/typescript/tests-ts/cli-project-config.test.ts @@ -4,6 +4,7 @@ import { readFile, realpath, rm, + symlink, writeFile, } from "node:fs/promises"; import { execFileSync } from "node:child_process"; @@ -53,9 +54,14 @@ async function fixture(input: ProjectConfigInput | string) { return { root, repository, configDirectory, config }; } -test.each([undefined, "starter.json"])( +test.each([ + [undefined, "./node_modules"], + ["starter.json", "./node_modules"], + ["settings/starter.yaml", "../node_modules"], + ["settings/starter.json", "../node_modules"], +])( "init writes a valid unpinned starter and refuses overwrites: %s", - async (file) => { + async (file, modules) => { const input = await fixture({}); const args = ["init", ...(file === undefined ? [] : [file]), "--json"]; const output = capture(); @@ -70,8 +76,7 @@ test.each([undefined, "starter.json"])( expect(JSON.parse(output.text())).toEqual({ path }); const selected = await readProjectConfig(path); expect(selected.input).toEqual({ - $schema: - "./node_modules/@openai/codex-security/schemas/project-config.schema.json", + $schema: `${modules}/@openai/codex-security/schemas/project-config.schema.json`, }); const contents = await readFile(path, "utf8"); expect(await main(args, capture().stream, capture().stream, deps)).toBe(2); @@ -273,13 +278,13 @@ test("rerun rejects a blank replacement when scan instructions are required", as expect(initialized).toBe(false); }); -test("bulk scans apply config per CSV mode, preserve scope overrides, and retain the policy on resume", async () => { +test("bulk scans apply config and linked operator prompts, preserve CSV scope overrides, and retain the policy on resume", async () => { const config: ProjectConfigInput = { auth: "api-key", scan: { scope: { paths: ["src"] }, knowledge_base: ["context.md"], - instructions_file: "instructions.md", + instructions_file: "linked/instructions.md", deep: { workers: 2, subagents_per_worker: 0 }, }, codex: { model: "gpt-5.6-terra" }, @@ -288,12 +293,21 @@ test("bulk scans apply config per CSV mode, preserve scope overrides, and retain output: { directory: "../batch-results" }, }; const input = await fixture(config); + const promptDirectory = await realpath( + await mkdtemp(join(tmpdir(), "bulk-operator-prompts-")), + ); + directories.push(promptDirectory); + await symlink( + promptDirectory, + join(input.configDirectory, "linked"), + "junction", + ); await writeFile( join(input.configDirectory, "context.md"), "Synthetic context.", ); await writeFile( - join(input.configDirectory, "instructions.md"), + join(promptDirectory, "instructions.md"), "Review synthetic boundaries.", ); await writeFile( diff --git a/sdk/typescript/tests-ts/cli-workbench.test.ts b/sdk/typescript/tests-ts/cli-workbench.test.ts index 87aae64c9..1252c5298 100644 --- a/sdk/typescript/tests-ts/cli-workbench.test.ts +++ b/sdk/typescript/tests-ts/cli-workbench.test.ts @@ -820,6 +820,7 @@ describe("CLI workbench", () => { let config: CodexSecurityConfig | undefined; let repository: string | undefined; let options: Record | undefined; + const knowledgeBasePath = resolve("/original/security.md"); const savedConfig = { approval_policy: "on-request", model: "gpt-original", @@ -847,7 +848,7 @@ describe("CLI workbench", () => { mode: "deep", pluginVersion: "1.2.3", failOnSeverity: "high", - knowledgeBasePaths: ["/original/security.md"], + knowledgeBasePaths: [knowledgeBasePath], deepScan: { workers: 2, subagents: 0, @@ -869,7 +870,7 @@ describe("CLI workbench", () => { parentScanId: "scan-original", expectedPluginVersion: "1.2.3", failureSeverity: "high", - knowledgeBasePaths: ["/original/security.md"], + knowledgeBasePaths: [knowledgeBasePath], workers: 2, subagents: 0, stopAfterNoNew: 3, diff --git a/sdk/typescript/tests-ts/cli.test.ts b/sdk/typescript/tests-ts/cli.test.ts index dbab706ef..c790a9613 100644 --- a/sdk/typescript/tests-ts/cli.test.ts +++ b/sdk/typescript/tests-ts/cli.test.ts @@ -750,6 +750,8 @@ describe("CLI", () => { test("runs a bulk scan and keeps structured output on stdout", async () => { const root = await mkdtemp(join(tmpdir(), "codex-security-cli-multiscan-")); + const architecturePath = resolve(root, "/shared/architecture.pdf"); + const threatModelsPath = resolve(root, "/shared/threat-models"); try { await multiscanInventory(root); const stdout = capture(); @@ -770,8 +772,8 @@ describe("CLI", () => { "--effort", "high", "--knowledge-base", - "/shared/architecture.pdf", - "--knowledge-base=/shared/threat-models", + architecturePath, + `--knowledge-base=${threatModelsPath}`, "--codex", "features.goals=true", "--json", @@ -801,10 +803,7 @@ describe("CLI", () => { }); expect(scanOptions).toMatchObject({ mode: "deep", - knowledgeBasePaths: [ - "/shared/architecture.pdf", - "/shared/threat-models", - ], + knowledgeBasePaths: [architecturePath, threatModelsPath], }); expect(stderr.text()).toContain("sample started (attempt 1)"); expect(stderr.text()).toContain("sample completed (attempt 1)"); diff --git a/sdk/typescript/tests-ts/multiscan.test.ts b/sdk/typescript/tests-ts/multiscan.test.ts index 8fd8ac9ba..42516a98b 100644 --- a/sdk/typescript/tests-ts/multiscan.test.ts +++ b/sdk/typescript/tests-ts/multiscan.test.ts @@ -25,6 +25,7 @@ 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"; +import { DiffTarget } from "../src/targets.js"; import { capture, dependencies, fakeResult } from "./cli-fixtures.js"; import { runTestInSubprocess } from "./support/test-subprocess.js"; @@ -135,6 +136,39 @@ async function results(path: string): Promise[]> { } describe("multiscan", () => { + test.each([DiffTarget.refs({ base: "HEAD~1" }), DiffTarget.workingTree()])( + "rejects unsupported bulk diff scopes before preparing a campaign: %j", + async (target) => { + const paths = await fixture(); + const source = await repository(paths.root, "configured-scope"); + await writeFile( + paths.input, + `id,repository,revision\nexample,${source.path},${source.revision}\n`, + ); + let initialized = false; + const security = client(async () => { + throw new Error("The unsupported target must not reach a scan."); + }); + await expect( + runMultiscan( + options(paths, security, { + scanOptionsByMode: { standard: { target } }, + createSecurity: () => { + initialized = true; + return security; + }, + }), + ), + ).rejects.toThrow( + "Bulk scans do not support diff or working-tree scopes", + ); + expect(initialized).toBe(false); + await expect(access(paths.output)).rejects.toMatchObject({ + code: "ENOENT", + }); + }, + ); + test("scopes GitHub CLI credentials to the discovered GitHub host", () => { expect(buildGitHubCredentialArgs(undefined)).toEqual([]); expect(buildGitHubCredentialArgs("github.com")).toEqual([ @@ -219,6 +253,9 @@ describe("multiscan", () => { scanPrompt: "Review boundaries.", postScanPrompt: "Draft confirmed fixes.", maxCostUsd: 12.5, + scanOptionsByMode: { + deep: { target: DiffTarget.workingTree() }, + }, }, ), ); From ddfeb21a5be23cf5f56557e4c67f4e33b008a1c4 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 31 Aug 2026 02:41:37 -0700 Subject: [PATCH 12/12] fix(config): preserve bulk prompt repository checks --- sdk/typescript/scripts/smoke-package.mjs | 9 +-- sdk/typescript/src/cli.ts | 24 +++----- sdk/typescript/src/multiscan.ts | 57 +++++++++++++++---- sdk/typescript/src/prompt-files.ts | 8 ++- .../tests-ts/cli-project-config.test.ts | 38 +++++++++++++ sdk/typescript/tests-ts/multiscan.test.ts | 31 ++++++++++ 6 files changed, 132 insertions(+), 35 deletions(-) diff --git a/sdk/typescript/scripts/smoke-package.mjs b/sdk/typescript/scripts/smoke-package.mjs index f855e8e9a..9e33735ad 100644 --- a/sdk/typescript/scripts/smoke-package.mjs +++ b/sdk/typescript/scripts/smoke-package.mjs @@ -493,15 +493,16 @@ try { assert.match(help, /\bpublish\b/u); assert.match(help, /\bdedupe\b/u); - // The CLI's working directory is canonical even when tmpdir() is an alias. - const starterPath = join(await realpath(consumer), "codex-security.yaml"); + const starterPath = join(consumer, "codex-security.yaml"); const starter = JSON.parse( run(process.execPath, [launcher, "init", "--json"], { cwd: consumer, capture: true, }), ); - assert.equal(starter.path, starterPath); + // Compare file identities across symlink aliases and Windows short names. + const canonicalStarterPath = await realpath(starterPath); + assert.equal(await realpath(starter.path), canonicalStarterPath); for (const args of [["-c", starterPath], []]) { const info = JSON.parse( run(process.execPath, [launcher, "info", ...args, "--json"], { @@ -510,7 +511,7 @@ try { env: { ...process.env, CODEX_SECURITY_PROJECT_CONFIG: starterPath }, }), ); - assert.equal(info.configuration.path, starterPath); + assert.equal(await realpath(info.configuration.path), canonicalStarterPath); assert.equal(info.configuration.settings.mode, "standard"); assert.equal(info.configuration.sources["scan.mode"], "default"); } diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index a3aaa7a10..a317781ae 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -3533,13 +3533,6 @@ export async function main( currentDirectory, ); const settings = resolved.options; - // These shared operator inputs precede the CSV repository checkouts; - // the invocation directory is not a scan-repository boundary. - const prompts = await resolveScanPrompts( - settings, - undefined, - currentDirectory, - ); // A CSV row may choose a different mode; resolve the same file for both // modes so inactive deep defaults remain available to deep rows. const scanOptionsByMode = @@ -3548,14 +3541,11 @@ export async function main( : Object.fromEntries( SCAN_MODES.map((mode) => [ mode, - { - ...resolveScanSettings( - project, - { ...overrides, mode }, - currentDirectory, - ).options, - ...prompts, - }, + resolveScanSettings( + project, + { ...overrides, mode }, + currentDirectory, + ).options, ]), ); let inputPath: string; @@ -3602,7 +3592,9 @@ export async function main( : { maxCostUsd: settings.maxCostUsd }), knowledgeBasePaths: settings.knowledgeBasePaths, scanOptionsByMode, - ...prompts, + scanPromptFile: settings.scanPromptFile, + validationPromptFile: settings.validationPromptFile, + postScanPromptFile: settings.postScanPromptFile, config: { codexOverrides: mergeCodexOverrides( resolved.config.codexOverrides, diff --git a/sdk/typescript/src/multiscan.ts b/sdk/typescript/src/multiscan.ts index d8fab1532..a31701c29 100644 --- a/sdk/typescript/src/multiscan.ts +++ b/sdk/typescript/src/multiscan.ts @@ -23,9 +23,10 @@ import type { CodexSecurityConfig } from "./config.js"; import type { ScanCost } from "./cost.js"; import { safeErrorMessage, ScanCostLimitExceededError } from "./errors.js"; import type { CoverageDocument } from "./models.js"; +import { resolveScanPrompts } from "./prompt-files.js"; import { requireSecureOutputAncestry } from "./runtime.js"; import { DiffTarget, type ScanMode } from "./targets.js"; -import type { ScanSettings } from "./scan-settings.js"; +import type { ScanPromptSettings, ScanSettings } from "./scan-settings.js"; import { workflowDigest } from "./finding-workflow.js"; import { resolveTrustedExecutable } from "./trusted-executable.js"; @@ -61,7 +62,7 @@ interface MultiscanReceipt extends MultiscanTask { policyFailed?: boolean; } -export interface MultiscanOptions { +export interface MultiscanOptions extends ScanPromptSettings { inputPath: string; outputDir: string; githubHost?: string; @@ -70,10 +71,10 @@ export interface MultiscanOptions { mode: ScanMode; maxAttempts: number; maxCostUsd?: number; - scanPrompt?: string; - validationPrompt?: string; - postScanPrompt?: string; - scanOptionsByMode?: Partial>; + // Prompts are shared across modes and prepared from the top-level options. + scanOptionsByMode?: Partial< + Record> + >; config: CodexSecurityConfig; createSecurity( config: CodexSecurityConfig, @@ -128,8 +129,43 @@ export async function runMultiscan( "Bulk scans do not support diff or working-tree scopes because their checkouts are clean, shallow snapshots. Use repository or path scopes instead.", ); } + const repositories: string[] = []; if ( - options.validationPrompt !== undefined && + [ + [options.scanPrompt, options.scanPromptFile], + [options.validationPrompt, options.validationPromptFile], + [options.postScanPrompt, options.postScanPromptFile], + ].some(([inline, file]) => inline === undefined && file !== undefined) + ) { + for (const repository of new Set(tasks.map((task) => task.repository))) { + if (!isAbsolute(repository)) continue; + try { + repositories.push(await realpath(repository)); + } catch (error) { + // Missing sources retain the campaign's per-repository failure behavior. + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT" && code !== "ENOTDIR") throw error; + } + } + } + // Shared inputs use actual local CSV sources as directory-link boundaries, + // not the invocation directory, and are read once before any scan starts. + const prompts = await resolveScanPrompts(options, repositories); + const resolvedOptions: MultiscanOptions = { + ...options, + ...prompts, + ...(options.scanOptionsByMode === undefined + ? {} + : { + scanOptionsByMode: Object.fromEntries( + Object.entries(options.scanOptionsByMode).map( + ([mode, settings]) => [mode, { ...settings, ...prompts }], + ), + ), + }), + }; + if ( + resolvedOptions.validationPrompt !== undefined && tasks.some((task) => task.mode === "deep") ) { throw new Error("Custom validation is not supported for Deep scans."); @@ -139,7 +175,7 @@ export async function runMultiscan( await requireSecureOutputAncestry(output); const unlock = await acquireLock(output); try { - const result = await runCampaign(options, tasks, output); + const result = await runCampaign(resolvedOptions, tasks, output); return (await realpath(requestedOutput).catch(() => undefined)) === output ? { ...result, resultsPath: join(requestedOutput, "results.jsonl") } : result; @@ -277,10 +313,7 @@ async function runCampaign( } } const scanSettings = options.scanOptionsByMode?.[task.mode]; - const scanPrompt = [ - options.scanPrompt?.trim() ?? scanSettings?.scanPrompt?.trim(), - task.prompt, - ] + const scanPrompt = [options.scanPrompt?.trim(), task.prompt] .filter(Boolean) .join("\n\n"); const result = await security.run(checkout, { diff --git a/sdk/typescript/src/prompt-files.ts b/sdk/typescript/src/prompt-files.ts index 831cca032..9b4b0b171 100644 --- a/sdk/typescript/src/prompt-files.ts +++ b/sdk/typescript/src/prompt-files.ts @@ -25,7 +25,7 @@ type ResolvedScanPrompts = Pick< /** Resolve selected files once; an inline SDK prompt overrides its file. */ export async function resolveScanPrompts( options: ScanPromptSettings, - repository: string | undefined, + repository: string | readonly string[], directory = process.cwd(), ): Promise { const read = async (inline: string | undefined, file: string | undefined) => @@ -67,7 +67,7 @@ export async function resolveScanPrompts( export async function readRegularInputFile( path: string, - repository: string | undefined, + repository: string | readonly string[], metadata?: Pick, ): Promise { const selected = metadata ?? (await lstat(path, { bigint: true })); @@ -75,7 +75,9 @@ export async function readRegularInputFile( throw new CodexSecurityError("Input files must be regular files."); } const canonicalParent = await realpath(dirname(path)); - if (repository !== undefined) { + const repositories = + typeof repository === "string" ? [repository] : repository; + for (const repository of repositories) { const canonicalRepository = await realpath(repository); if (isOutsidePath(relative(canonicalRepository, canonicalParent))) { for (let ancestor = dirname(path); ; ancestor = dirname(ancestor)) { diff --git a/sdk/typescript/tests-ts/cli-project-config.test.ts b/sdk/typescript/tests-ts/cli-project-config.test.ts index 4aa23f28f..a1d6a9cda 100644 --- a/sdk/typescript/tests-ts/cli-project-config.test.ts +++ b/sdk/typescript/tests-ts/cli-project-config.test.ts @@ -278,6 +278,44 @@ test("rerun rejects a blank replacement when scan instructions are required", as expect(initialized).toBe(false); }); +test.each(["instructions_file", "validation_file"] as const)( + "bulk %s retains directory-link protection for local CSV repositories", + async (file) => { + const input = await fixture({ + scan: { [file]: "../repository/linked/prompt.md" }, + output: { directory: "../batch-results" }, + }); + const external = join(input.root, "external"); + await mkdir(external); + await writeFile(join(external, "prompt.md"), "Synthetic external data."); + await symlink(external, join(input.repository, "linked"), "junction"); + const csv = join(input.root, "repositories.csv"); + await writeFile( + csv, + `id,repository,revision\nsource,${input.repository},${"a".repeat(40)}\n`, + ); + let initialized = false; + const stderr = capture(); + const exit = await main( + ["bulk-scan", csv, "-c", input.config, "--json"], + capture().stream, + stderr.stream, + dependencies({ + currentDirectory: input.repository, + onConfig: () => { + initialized = true; + throw new Error("Runtime must not start"); + }, + }), + ); + expect(exit).toBe(2); + expect(stderr.text()).toContain( + "Input files must not follow repository directory links", + ); + expect(initialized).toBe(false); + }, +); + test("bulk scans apply config and linked operator prompts, preserve CSV scope overrides, and retain the policy on resume", async () => { const config: ProjectConfigInput = { auth: "api-key", diff --git a/sdk/typescript/tests-ts/multiscan.test.ts b/sdk/typescript/tests-ts/multiscan.test.ts index 42516a98b..77a4f3c4f 100644 --- a/sdk/typescript/tests-ts/multiscan.test.ts +++ b/sdk/typescript/tests-ts/multiscan.test.ts @@ -136,6 +136,37 @@ async function results(path: string): Promise[]> { } describe("multiscan", () => { + test("prepares shared prompt files once while missing sources remain row failures", async () => { + const paths = await fixture(); + const source = await repository(paths.root, "prompt-source"); + const prompt = join(paths.root, "shared-prompt.md"); + await writeFile(prompt, "Review synthetic boundaries."); + await writeFile( + paths.input, + `id,repository,revision\nmissing,${join(paths.root, "absent")},${source.revision}\nfirst,${source.path},${source.revision}\nsecond,${source.path},${source.revision}\n`, + ); + let scans = 0; + const summary = await runMultiscan( + options( + paths, + client(async (_checkout, scanOptions = {}) => { + expect(scanOptions.scanPrompt).toBe("Review synthetic boundaries."); + expect(scanOptions.scanPromptFile).toBeUndefined(); + if (scans++ === 0) await rm(prompt); + return await completedScan(scanOptions.outputDir!); + }), + { maxAttempts: 1, scanPromptFile: prompt }, + ), + ); + expect(scans).toBe(2); + expect(summary).toMatchObject({ total: 3, completed: 2, failed: 1 }); + expect(await results(summary.resultsPath)).toMatchObject([ + { id: "missing", status: "failed" }, + { id: "first", status: "completed" }, + { id: "second", status: "completed" }, + ]); + }); + test.each([DiffTarget.refs({ base: "HEAD~1" }), DiffTarget.workingTree()])( "rejects unsupported bulk diff scopes before preparing a campaign: %j", async (target) => {