diff --git a/README.md b/README.md index 15f44770f..b78f9abc4 100644 --- a/README.md +++ b/README.md @@ -89,3 +89,6 @@ 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. + +See [project configuration](docs/project-configuration.md) for reusable YAML/JSON +settings, CLI overrides, and editor schema support. diff --git a/docs/examples/codex-security.json b/docs/examples/codex-security.json new file mode 100644 index 000000000..84eda53b6 --- /dev/null +++ b/docs/examples/codex-security.json @@ -0,0 +1,12 @@ +{ + "$schema": "./node_modules/@openai/codex-security/schemas/project-config.schema.json", + "scan": { + "mode": "standard", + "scope": { "paths": ["sdk/typescript/src"] } + }, + "codex": { + "model": "gpt-5.6-sol", + "model_reasoning_effort": "xhigh" + }, + "policy": { "fail_on_severity": "high" } +} diff --git a/docs/examples/codex-security.yaml b/docs/examples/codex-security.yaml new file mode 100644 index 000000000..e7b888818 --- /dev/null +++ b/docs/examples/codex-security.yaml @@ -0,0 +1,11 @@ +# 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: + paths: [sdk/typescript/src] +codex: + model: gpt-5.6-sol + model_reasoning_effort: xhigh +policy: + fail_on_severity: high diff --git a/docs/project-configuration.md b/docs/project-configuration.md new file mode 100644 index 000000000..81fa26b8d --- /dev/null +++ b/docs/project-configuration.md @@ -0,0 +1,343 @@ +# Project configuration + +Use `scan [repository] -c FILE` or `scan [repository] --config FILE` to load one +YAML or JSON file: + +```sh +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`. `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 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: + +```yaml +scan: + scope: + paths: [src] + knowledge_base: [SECURITY.md, docs/architecture.md] +codex: + model: gpt-5.6-sol + model_reasoning_effort: xhigh +policy: + 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: +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 +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 `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 +inputs. + +## SDK and CLI contract + +The SDK's `ScanSettings` type is shared by `ScanOptions`, CLI resolution, and +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()`: + +```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: { max_cost_usd_per_scan: 5 }, +} satisfies ProjectConfigInput; +const { config, options } = resolveProjectConfig(input, process.cwd()); +``` + +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 +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`. An unknown threshold throws, +including when the result has no findings. + +## Overrides and paths + +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 | 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 | + +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. + +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` 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 +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 + +```yaml +scan: + mode: deep + deep: + workers: 4 + subagents_per_worker: 3 + stop_after_no_new: 4 + stop_after_consecutive_errors: 3 + max_discovery_runs: 40 + max_time_hours: 96 +limits: + max_cost_usd_per_scan: 10 +``` + +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. +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. +Counts retain their existing bounds; zero subagents is valid, and discovery time +cannot exceed 96 hours. + +`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. `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. +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. + +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 +and `deepScanSources`, including when no project file was selected; invalid +applicable legacy settings now fail before runtime startup. + +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. +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, 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 +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 +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 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. 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. Additional scan instructions are not retained: a new +recipe records that requirement, and `scans rerun` refuses to omit them silently. +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/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 6dcdab97d..f33dcb63b 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -135,21 +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. | -| `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 @@ -160,6 +164,46 @@ 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. + +`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 `snake_case` keys as YAML/JSON and returns the same `{ config, +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 +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 +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 @@ -263,7 +307,73 @@ 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 + +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. `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, 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 +# 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: + fail_on_severity: high +``` + +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 +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. 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. +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 @@ -271,6 +381,10 @@ runs local preflight checks. 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. @@ -316,7 +430,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 \ @@ -324,6 +438,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: @@ -389,6 +509,7 @@ await security.run("/path/to/repository", { workers: 2, subagents: 0, stopAfterNoNew: 3, + stopAfterConsecutiveErrors: 2, maxDiscoveryRuns: 10, maxTimeHours: 1.5, }); @@ -406,8 +527,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.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 four workers. Unknown keys are rejected. @@ -417,6 +540,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 `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 +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 @@ -475,23 +604,24 @@ restrictions. ### Environment variables -| Variable | Effect | -| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | -| `OPENAI_API_KEY`, `CODEX_API_KEY` | Scan credentials; `OPENAI_API_KEY` wins if both are set. | -| `CODEX_SECURITY_EMBEDDINGS_URL` | Findings service endpoint; see [Embeddings and storage](#embeddings-and-storage). | -| `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_EMBEDDINGS_URL` | Findings service endpoint; see [Embeddings and storage](#embeddings-and-storage). | +| `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`; @@ -544,6 +674,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. @@ -561,7 +696,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, { @@ -762,6 +898,18 @@ 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 +`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 | | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | `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 0b0889e7e..8e0825339 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -22,7 +22,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" @@ -30,6 +31,7 @@ "files": [ "bin", "dist", + "schemas", "_bundled_plugin", "LICENSE", "README.md" @@ -43,7 +45,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", @@ -58,7 +60,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": "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", @@ -77,7 +79,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..263de9e6f --- /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": { + "working_tree": { + "type": "object", + "properties": { + "base": { + "default": "HEAD", + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "required": ["working_tree"], + "additionalProperties": false + } + ] + }, + "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 } + }, + "instructions_file": { + "description": "Additional scan instructions, relative to this file.", + "type": "string", + "minLength": 1 + }, + "validation_file": { + "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 + }, + "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 + }, + "stop_after_consecutive_errors": { + "default": 3, + "description": "Stop after this many consecutive discovery errors.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "max_discovery_runs": { + "default": 40, + "description": "Maximum deep-scan discovery runs.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "max_time_hours": { + "default": 96, + "description": "Maximum deep-scan discovery hours (default: 96; maximum: 96).", + "type": "number", + "exclusiveMinimum": 0, + "maximum": 96 + } + }, + "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": { + "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 + } + }, + "additionalProperties": false + }, + "policy": { + "type": "object", + "properties": { + "fail_on_severity": { + "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..9ab6e9689 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( [ @@ -173,6 +175,7 @@ const distFiles = new Set( "component-plan", "component-scan", "config", + "config-path", "contract", "cost", "cost-model", @@ -180,6 +183,13 @@ const distFiles = new Set( "custom-validation-prompt", "custom-publish", "deep-progress", + "deep-config", + "deep-scan-defaults", + "project-config", + "project-config-schema", + "prompt-files", + "scan-modes", + "scan-settings", "errors", "github", "index", @@ -253,6 +263,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/fixtures/package-consumer.ts b/sdk/typescript/scripts/fixtures/package-consumer.ts index 045ce3076..13c0c0576 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,35 @@ export async function scan(repository: string): Promise { } } +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; +} + +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/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..7a0e1a789 --- /dev/null +++ b/sdk/typescript/scripts/generate-project-config-schema.mjs @@ -0,0 +1,34 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; +import { format } from "prettier"; + +// 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 94d82aa42..9e33735ad 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, @@ -399,7 +400,20 @@ 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: { 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); + 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.sources["scan.deep.subagents_per_worker"], "project"); + assert.equal(loaded.sources["output.directory"], "default"); + assert.equal(Object.isFrozen(loaded.sources), true);`, ], { cwd: consumer }, ); @@ -479,6 +493,44 @@ 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, + }), + ); + // 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"], { + cwd: consumer, + capture: true, + env: { ...process.env, CODEX_SECURITY_PROJECT_CONFIG: starterPath }, + }), + ); + assert.equal(await realpath(info.configuration.path), canonicalStarterPath); + assert.equal(info.configuration.settings.mode, "standard"); + 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/api.ts b/sdk/typescript/src/api.ts index f8113895a..7b163a798 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -28,11 +28,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, @@ -68,6 +63,26 @@ import { DeepScanProgressTracker, type DeepScanProgress, } from "./deep-progress.js"; +import { + deepScanOptions, + resolveDeepScanConfig, + writeDeepScanConfig, + type DeepScanSources, + 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 { loadContract, readScanFile, @@ -161,7 +176,6 @@ import { resolveRepositoryPath, type NormalizedTarget, type ScanMode, - type ScanTarget, validatedGitEnvironment, validateCommittedDiffCheckout, validateMode, @@ -218,32 +232,14 @@ 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 { +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; @@ -293,9 +289,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"; @@ -362,12 +355,15 @@ export interface ScanPreflight extends DeepScanOptions { modelProvider?: string; reasoningEffort: string; maxCostUsd?: number; + deepScanSources?: DeepScanSources; } interface LocalScanInputs extends Omit { protectedRoot: string; stateDirectory: string; + deepScanConfiguration?: ResolvedDeepScanConfig; + prompts: ScanPromptSettings; } export interface CodexSecurityMetadata { @@ -409,13 +405,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 = { @@ -474,7 +463,7 @@ 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, @@ -493,8 +482,9 @@ export class CodexSecurity { config: this.config, options: { ...options, + ...local.prompts, target: options.target ?? "repository", - mode: options.mode ?? "standard", + mode: options.mode ?? DEFAULT_SCAN_MODE, outputDir: options.outputDir === undefined ? undefined @@ -543,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, @@ -589,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, @@ -686,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, @@ -723,7 +713,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 } : {}), @@ -742,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([ @@ -750,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; @@ -783,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, @@ -791,13 +800,16 @@ export class CodexSecurity { outputDir: requestedOutput, protectedRoot, stateDirectory, - } = await this.#validateLocalInputs(repository, options, signal); + deepScanConfiguration, + 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", @@ -837,13 +849,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 = @@ -867,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, ); }, @@ -882,8 +892,8 @@ export class CodexSecurity { requireModelSafeOutputDir(scanDir); notifyObserver( "onOutputDirReady", - options.onOutputDirReady, - options.onObserverError, + resolvedOptions.onOutputDirReady, + resolvedOptions.onObserverError, scanDir, ); checkOpen(); @@ -906,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, @@ -939,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, @@ -961,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)}`, ); }; @@ -983,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, ), @@ -1032,19 +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, - deepScanOptions(options), - ); - 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)}`, ); } @@ -1626,8 +1645,8 @@ export class CodexSecurity { } catch (error) { notifyObserver( "onWarning", - options.onWarning, - options.onObserverError, + resolvedOptions.onWarning, + resolvedOptions.onObserverError, `Could not update repository findings: ${errorMessage(error)}`, ); } @@ -1647,7 +1666,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( @@ -1676,7 +1695,9 @@ export class CodexSecurity { budgetRecovery.expectation, AbortSignal.any([ this.#abortController.signal, - ...(options.signal === undefined ? [] : [options.signal]), + ...(resolvedOptions.signal === undefined + ? [] + : [resolvedOptions.signal]), ]), true, ); @@ -1704,8 +1725,8 @@ export class CodexSecurity { : [failure.message]) { notifyObserver( "onWarning", - options.onWarning, - options.onObserverError, + resolvedOptions.onWarning, + resolvedOptions.onObserverError, warning, targetWarnings.has(warning) ? { kind: "target_changed" } @@ -1718,7 +1739,7 @@ export class CodexSecurity { } if (activeScan !== null) { if ( - options.validationPrompt !== undefined && + resolvedOptions.validationPrompt !== undefined && !customValidationComplete ) { await writeCustomValidationStatus(scanDir, { @@ -1751,8 +1772,8 @@ export class CodexSecurity { } catch (postScanError) { notifyObserver( "onWarning", - options.onWarning, - options.onObserverError, + resolvedOptions.onWarning, + resolvedOptions.onObserverError, `Could not run post-scan instructions: ${errorMessage(postScanError)}`, ); } @@ -1775,11 +1796,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. @@ -1787,7 +1808,7 @@ export class CodexSecurity { await releaseCredentialHome?.(); } catch (error) { if (!scanFailure) throw error; - warnCleanupFailed(options, error); + warnCleanupFailed(resolvedOptions, error); } } } @@ -2344,12 +2365,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 && @@ -2364,7 +2385,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.", @@ -2377,12 +2398,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.", @@ -2397,13 +2419,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, ); @@ -2430,6 +2446,26 @@ export class CodexSecurity { outputDir: requestedOutput, protectedRoot, stateDirectory, + prompts, + ...(mode === "deep" + ? { + deepScanConfiguration: await resolveDeepScanConfig( + deep, + join( + expandHome( + environmentValue( + this.#dependencies.environment, + "CODEX_HOME", + ) ?? join(homedir(), ".codex"), + this.#dependencies.environment, + ), + "codex-security", + "config.toml", + ), + signal, + ), + } + : {}), }; } @@ -2562,94 +2598,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 { @@ -3154,18 +3102,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?: DeepScanOptions, -): 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: { @@ -3179,16 +3140,29 @@ function scanRecipe( mode, ...(repositoryRevision === null ? {} : { repositoryRevision }), pluginVersion, - config: preflightConfig, + config, + ...(auth === undefined ? {} : { auth }), ...(failOnSeverity === undefined ? {} : { failOnSeverity }), ...(knowledgeBasePaths === undefined ? {} : { knowledgeBasePaths }), ...(maxCostUsd === undefined ? {} : { maxCostUsd }), - ...(deepScan === undefined || Object.keys(deepScan).length === 0 + ...(deepScan === undefined ? {} - : { deepScan: { ...deepScan } }), + : { deepScan: { ...deepScan }, deepScanResolved: true }), }; } +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, @@ -3260,7 +3234,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/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 fa9c228dc..b5f0a1d07 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"; @@ -86,7 +83,9 @@ import { DEFAULT_CODEX_CONFIG, EXTERNAL_CODEX_PROVIDERS, isExternalModelProvider, + mergeCodexOverrides, mergedCodexConfig, + scanModel, scanModelConfiguration, scanModelProvider, type CodexSecurityConfig, @@ -95,6 +94,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, @@ -168,12 +172,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, @@ -186,6 +185,34 @@ import { VERSION, } from "./version.js"; +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, + DEFAULT_SCAN_AUTH, + FailureSeveritySchema, + REPORTABLE_SEVERITIES, + SCAN_SEVERITIES, + ScanSettingsSchema, + pickScanSettings, + meetsSeverity, + type FailureSeverity, + type ResolvedScanSettings, +} from "./scan-settings.js"; + const PROGRESS_REFRESH_MILLISECONDS = 1_000; const execFile = promisify(execFileCallback); const WINDOWS_NETWORK_PATH = /^[\\/]{2}/u; @@ -208,18 +235,8 @@ type Writable = Pick & { readonly columns?: number; }; type SignalName = "SIGINT" | "SIGTERM"; -type FailureSeverity = Exclude; -const REPORTABLE_SEVERITIES: readonly FailureSeverity[] = [ - "critical", - "high", - "medium", - "low", -]; -const DISPLAY_SEVERITIES: readonly SeverityLevel[] = [ - ...REPORTABLE_SEVERITIES, - "informational", -]; +const DISPLAY_SEVERITIES: readonly SeverityLevel[] = SCAN_SEVERITIES; const MODEL_REASONING_EFFORTS = [ "minimal", "low", @@ -237,12 +254,19 @@ 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", sarif: "results.sarif", } as const; const VALUE_OPTIONS = new Set([ + "--config", + "-c", "--port", "--workflow-id", "--auth", @@ -852,158 +876,34 @@ 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)."), -}; - -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)); +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 DeepScanOptions { +interface ScanArguments extends ResolvedScanSettings { + codexOverrides: JsonObject; + 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; @@ -1930,6 +1830,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( @@ -1951,10 +1856,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) { @@ -2797,6 +2718,7 @@ export async function main( description: "Run a Codex Security scan.", destructive: true, mcp: false, + alias: { config: "c" }, args: z.object({ repository: z .string() @@ -2805,17 +2727,15 @@ export async function main( }), options: z .object({ + config: PROJECT_CONFIG_OPTION, workflowId: optionValue("--workflow-id") .optional() .describe( "Reuse completed work in the named local findings workflow.", ), - auth: z - .enum(SCAN_AUTH_MODES) - .default("auto") - .describe( - "Select ChatGPT, OPENAI_API_KEY/CODEX_API_KEY, or automatic authentication.", - ), + auth: ScanSettingsSchema.shape.auth.describe( + "Select ChatGPT, OPENAI_API_KEY/CODEX_API_KEY, or automatic authentication (default: auto).", + ), verbose: z .boolean() .default(false) @@ -2827,13 +2747,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.", ), @@ -2853,7 +2775,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() @@ -2861,10 +2784,9 @@ export async function main( base: optionValue("--base") .optional() .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."), + mode: ScanSettingsSchema.shape.mode.describe( + "Scan mode (default: standard); deep supports repository and path targets.", + ), ...DEEP_SCAN_OPTION_SCHEMAS, model: optionValue("--model") .optional() @@ -2892,10 +2814,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) @@ -2905,11 +2826,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) @@ -2921,32 +2840,6 @@ export async function main( .default(false) .describe("Validate local scan inputs without starting a scan."), }) - .refine( - (options) => - Number(options.path.length > 0) + - Number(options.diff !== undefined) + - Number(options.workingTree) <= - 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, { @@ -2958,19 +2851,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: "." }, @@ -2996,48 +2883,85 @@ 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, + let outcome: ScanOutcome; + try { + const directory = dependencies.currentDirectory(); + const project = await selectedProjectConfig( + options.config, + dependencies, + ); + const scope = resolveCliScope(project?.input.scan?.scope, { 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", - ); + }); + const { + config, + options: settings, + projectConfig: provenance, + } = resolveScanSettings( + project, + { + auth: options.auth, + target: scope.target, + knowledgeBasePaths: options.knowledgeBase, + scanPromptFile: options.scanPromptFile, + validationPromptFile: options.validationPromptFile, + postScanPromptFile: options.postScanPromptFile, + mode: options.mode, + workers: options.workers, + subagents: options.subagents, + stopAfterNoNew: options.stopAfterNoNew, + maxDiscoveryRuns: options.maxDiscoveryRuns, + maxTimeHours: options.maxTimeHours, + outputDir: options.outputDir, + failureSeverity: options.failOnSeverity, + maxCostUsd: options.maxCost, + codexOverrides: parseCodexOverrides( + options.codex, + options.model, + options.effort, + options.provider, + project?.input.codex, + ), + }, + directory, + scope.sources, + ); + if (options.archiveExisting && settings.outputDir === undefined) { + throw new CodexSecurityError( + "--archive-existing requires --output-dir.", + ); + } + outcome = await runScan( + { + ...settings, + codexOverrides: config.codexOverrides, + projectConfig: provenance, + workflowId: options.workflowId, + safetyIdentifier: options.safetyIdentifier, + verbose: options.verbose, + repository: args.repository, + archiveExisting: options.archiveExisting, + pluginPath: options.pluginPath, + pythonPath: options.python, + 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({ @@ -3224,6 +3148,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() @@ -3233,12 +3158,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([]) @@ -3262,18 +3185,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() @@ -3337,15 +3263,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 @@ -3369,7 +3318,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, @@ -3390,25 +3340,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 readPromptFiles( - directory, - options.scanPromptFile, - options.postScanPromptFile, - repository, - )), - ...(options.maxCost === undefined - ? {} - : { maxCostUsd: options.maxCost }), + ...settings, + ...(await resolveScanPrompts(settings, repository, directory)), }, createSecurity: dependencies.createSecurity, planComponents: dependencies.planComponents, @@ -3453,7 +3392,9 @@ export async function main( result.incomplete || result.deduplication?.status === "incomplete" ? 2 - : 0); + : result.policyFailed + ? 1 + : 0); return { ...result }; } catch (error) { stopDashboard(); @@ -3471,6 +3412,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() @@ -3481,6 +3423,7 @@ export async function main( ), }), options: z.object({ + config: PROJECT_CONFIG_OPTION, outputDir: z .string() .min(1, "--output-dir must not be empty.") @@ -3490,7 +3433,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() @@ -3500,10 +3444,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."), @@ -3572,13 +3515,40 @@ export async function main( dependencies.addSignalListener("SIGTERM", onTerminate); try { const currentDirectory = dependencies.currentDirectory(); - const prompts = await readPromptFiles( - currentDirectory, - options.scanPromptFile, - options.postScanPromptFile, + 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, - options.validationPromptFile, ); + const settings = resolved.options; + // 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, + ]), + ); let inputPath: string; let outputDir: string; let githubHost: string | undefined; @@ -3596,41 +3566,49 @@ 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, - ...prompts, + : { maxCostUsd: settings.maxCostUsd }), + knowledgeBasePaths: settings.knowledgeBasePaths, + scanOptionsByMode, + scanPromptFile: settings.scanPromptFile, + validationPromptFile: settings.validationPromptFile, + postScanPromptFile: settings.postScanPromptFile, 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, @@ -3643,7 +3621,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 = @@ -4404,8 +4386,42 @@ 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 directory = dependencies.currentDirectory(); + const path = resolveCliPath( + directory, + args.file ?? "codex-security.yaml", + ); + await writeFile(path, projectConfigStarter(path, directory), { + 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, @@ -4425,8 +4441,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, @@ -4436,8 +4475,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), + }, }; }, }); @@ -4508,16 +4552,25 @@ 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 && scanPromptFile === undefined) { + throw new CodexSecurityError( + "This scan used additional instructions that are not retained. Supply --scan-prompt-file to rerun it.", + ); + } if ( recipe["validationMode"] === "custom" && validationPromptFile === undefined @@ -4617,15 +4670,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 && @@ -4635,23 +4700,38 @@ 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, - paths, - knowledgeBasePaths, - validationPromptFile, - diff: kind === "refs" ? reference : undefined, - workingTree: kind === "working_tree", - head: kind === "refs" ? head ?? "HEAD" : undefined, - base: kind === "working_tree" ? reference : undefined, + 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: knowledgeBasePaths.map((path) => + resolveCliPath(directory, path), + ), + ...prompts, mode, ...deepScan.data, archiveExisting: false, - codex: [], 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, @@ -4685,6 +4765,7 @@ function validateCliArguments( "logout", "serve", "info", + "init", ].includes(value), ); if (commandIndex < 0) return undefined; @@ -4760,6 +4841,7 @@ function validateCliArguments( "model", "reasoningEffort", "nextStep", + "configuration", ]); for (let index = 0; index < argv.length; index += 1) { const argument = argv[index]!; @@ -4926,11 +5008,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, @@ -6111,10 +6188,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, @@ -6409,25 +6482,16 @@ 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, 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 = { @@ -6473,14 +6537,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" @@ -6557,26 +6621,16 @@ async function executeScan( } security = dependencies.createSecurity(config); const options: ScanOptions = { + ...pickScanSettings(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, - 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, @@ -6635,7 +6689,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 +6949,27 @@ async function executeScan( verified: effectivePreflight.authentication.verified, }); progress?.stopTimer(); - return { exitCode: 0, data: { dryRun: true, ...effectivePreflight } }; + return { + exitCode: 0, + data: { + dryRun: true, + ...effectivePreflight, + ...(arguments_.projectConfig === undefined + ? {} + : { + projectConfig: { + ...arguments_.projectConfig, + sources: configurationSources( + arguments_.projectConfig.sources, + preflight.deepScanSources, + ), + }, + scanPromptFile: arguments_.scanPromptFile, + validationPromptFile: arguments_.validationPromptFile, + failOnSeverity: arguments_.failureSeverity, + }), + }, + }; } if (result === null) { diagnostic("scan.failed", { @@ -6905,7 +6979,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"), @@ -7182,8 +7256,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) || @@ -7193,10 +7270,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; } @@ -7441,18 +7520,87 @@ 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" }); +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: { + paths?: string[]; + diff?: string; + workingTree?: boolean; + head?: string; + base?: string; + }, +): { + target?: ScanTarget; + sources: Partial>; +} { + const sources: Partial> = {}; + 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.", + ); + 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"; + if (overrides.paths?.length) scope = { paths: overrides.paths }; + else if (overrides.diff !== undefined) + scope = { diff: { base: overrides.diff } }; + else if (overrides.workingTree === true) scope = { working_tree: {} }; + else if ( + overrides.workingTree === false && + scope !== undefined && + "working_tree" in scope + ) { + scope = undefined; + changed = true; + } + if (explicitScopes > 0 || changed) { + 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."); + scope = { diff: { ...scope.diff, head: overrides.head } }; + sources["scan.scope.diff.head"] = "cli"; + changed = true; + } + if (overrides.base !== undefined) { + if (scope === undefined || !("working_tree" in scope)) + throw new ConfigurationError("--base requires --working-tree."); + scope = { working_tree: { base: overrides.base } }; + sources["scan.scope.working_tree.base"] = "cli"; + changed = true; } - return "repository"; + return { + ...(changed ? { target: projectScopeTarget(scope) ?? "repository" } : {}), + sources, + }; } export function parseCodexOverrides( @@ -7460,6 +7608,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; @@ -7532,13 +7681,17 @@ export function parseCodexOverrides( } cursor[final] = parsed; } - if ( - (isExternalModelProvider(provider) || provider === "amazon-bedrock") && - !("model" in result) - ) { - 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/config.ts b/sdk/typescript/src/config.ts index 27cbddd1b..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 && @@ -338,6 +343,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..7fb83291c --- /dev/null +++ b/sdk/typescript/src/deep-config.ts @@ -0,0 +1,203 @@ +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"; +import { CodexSecurityError } from "./errors.js"; +import { + DEFAULT_SCAN_MODE, + DEEP_SCAN_SETTINGS, + DeepScanSettingsSchema, + type DeepScanOptions, +} from "./scan-settings.js"; +import type { ScanMode } from "./scan-modes.js"; + +export type DeepScanSources = Record< + keyof DeepScanOptions, + "default" | "legacy" | "override" +>; +export interface ResolvedDeepScanConfig { + settings: Required; + sources: DeepScanSources; + source: string; + document: TomlTable; + overrides: DeepScanOptions; +} + +export function deepScanOptions( + options: DeepScanOptions & { mode?: ScanMode }, +): DeepScanOptions { + const selected: DeepScanOptions = {}; + 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."); + } + 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, + 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)) { + document = await readDeepScanDocument(source, signal); + } + 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 settings = { + ...DEFAULT_DEEP_SCAN_SETTINGS, + } as Required; + const sources = {} as DeepScanSources; + for (const [name, key] of DEEP_SCAN_SETTINGS) { + 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, + ); + } + return { + settings, + sources, + source, + document, + overrides: explicit, + }; +} + +export async function writeDeepScanConfig( + destination: string, + resolved: ResolvedDeepScanConfig, +): Promise { + const [source, target] = await Promise.all([ + canonicalConfigPath(resolved.source), + runtimeConfigPath(destination), + ]); + let document = resolved.document; + 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: { + ...(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 runtimeConfigPath(path: string): Promise { + try { + return await canonicalConfigPath(path); + } catch (error) { + if ( + (error as NodeJS.ErrnoException).code !== "ELOOP" || + !(await lstat(path)).isSymbolicLink() + ) + throw error; + // 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; + } + } +} + +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/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/index.ts b/sdk/typescript/src/index.ts index 29de039dd..7a4d66dbd 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, @@ -125,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..a31701c29 100644 --- a/sdk/typescript/src/multiscan.ts +++ b/sdk/typescript/src/multiscan.ts @@ -23,8 +23,11 @@ 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 type { ScanMode } from "./targets.js"; +import { DiffTarget, type ScanMode } from "./targets.js"; +import type { ScanPromptSettings, ScanSettings } from "./scan-settings.js"; +import { workflowDigest } from "./finding-workflow.js"; import { resolveTrustedExecutable } from "./trusted-executable.js"; const execFile = promisify(execFileCallback); @@ -56,9 +59,10 @@ interface MultiscanReceipt extends MultiscanTask { cost?: ScanCost; error?: string; warning?: string; + policyFailed?: boolean; } -export interface MultiscanOptions { +export interface MultiscanOptions extends ScanPromptSettings { inputPath: string; outputDir: string; githubHost?: string; @@ -67,9 +71,10 @@ export interface MultiscanOptions { mode: ScanMode; maxAttempts: number; maxCostUsd?: number; - scanPrompt?: string; - validationPrompt?: string; - postScanPrompt?: string; + // Prompts are shared across modes and prepared from the top-level options. + scanOptionsByMode?: Partial< + Record> + >; config: CodexSecurityConfig; createSecurity( config: CodexSecurityConfig, @@ -95,6 +100,7 @@ export interface MultiscanResult { failed: number; skipped: number; resultsPath: string; + policyFailed?: boolean; } export async function runMultiscan( @@ -113,7 +119,53 @@ export async function runMultiscan( options.mode, ); if ( - options.validationPrompt !== undefined && + 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.", + ); + } + const repositories: string[] = []; + if ( + [ + [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."); @@ -123,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; @@ -145,6 +197,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 +223,7 @@ async function runCampaign( (await hasArtifacts(artifactOutput)) ) { if (receipt.status === "completed") { + policyFailed ||= receipt.policyFailed === true; completed += 1; continue; } @@ -178,6 +235,7 @@ async function runCampaign( outputDir: artifactOutput, }); if (coverage !== undefined) { + policyFailed ||= receipt.policyFailed === true; incomplete += 1; notifyProgress(options, { repository: task.id, @@ -201,6 +259,7 @@ async function runCampaign( failed: 0, skipped, resultsPath: ledger, + ...(hasPolicy ? { policyFailed } : {}), }; } @@ -228,6 +287,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 +312,12 @@ async function runCampaign( throw new Error("Multiscan scope escapes its repository."); } } + const scanSettings = options.scanOptionsByMode?.[task.mode]; const scanPrompt = [options.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 +343,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 +384,9 @@ async function runCampaign( ...(cost === null ? {} : { cost }), ...(failure === undefined ? {} : { error: failure }), ...(warning === undefined ? {} : { warning }), + ...(attemptPolicyFailed === undefined + ? {} + : { policyFailed: attemptPolicyFailed }), })}\n`, ); notifyProgress(options, { @@ -326,6 +396,7 @@ async function runCampaign( ...(warning === undefined ? {} : { warning }), }); if (failure === undefined) { + policyFailed ||= attemptPolicyFailed === true; if (warning === undefined) completed += 1; else incomplete += 1; break; @@ -360,6 +431,7 @@ async function runCampaign( failed, skipped, resultsPath: ledger, + ...(hasPolicy ? { policyFailed } : {}), }; } @@ -631,7 +703,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 +727,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-schema.ts b/sdk/typescript/src/project-config-schema.ts new file mode 100644 index 000000000..8e9fbe955 --- /dev/null +++ b/sdk/typescript/src/project-config-schema.ts @@ -0,0 +1,120 @@ +import { z } from "zod"; +import { + DeepScanSettingsSchema, + FailureSeveritySchema, + ScanSettingsSchema, +} 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({ + working_tree: 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: ScanSettingsSchema.shape.auth.describe( + "Credential-source choice only; never a credential value.", + ), + scan: z + .strictObject({ + mode: ScanSettingsSchema.shape.mode, + scope: ProjectScopeSchema.optional().describe( + "One scope variant. Omit for the whole repository. Mode compatibility is checked after overrides.", + ), + knowledge_base: ScanSettingsSchema.shape.knowledgeBasePaths.describe( + "Context files or directories, relative to this file. An empty list selects no additional context.", + ), + instructions_file: ScanSettingsSchema.shape.scanPromptFile.describe( + "Additional scan instructions, relative to this file.", + ), + validation_file: ScanSettingsSchema.shape.validationPromptFile.describe( + "Custom validation instructions, relative to this file; not supported in active deep scans.", + ), + 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( + "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({ + 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({ + fail_on_severity: FailureSeveritySchema.optional().describe( + "Exit threshold; does not filter retained findings. Omit for report-only behavior.", + ), + }) + .optional(), + output: z + .strictObject({ + directory: ScanSettingsSchema.shape.outputDir.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..e853bcbee --- /dev/null +++ b/sdk/typescript/src/project-config.ts @@ -0,0 +1,385 @@ +import { readFile } from "node:fs/promises"; +import { + dirname, + extname, + isAbsolute, + relative, + resolve, + sep, +} from "node:path"; +import { pathToFileURL } from "node:url"; +import Ajv from "ajv"; +import { parseDocument } from "yaml"; +import { + DEFAULT_CODEX_CONFIG, + mergeCodexOverrides, + 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, + type ProjectScope, +} from "./project-config-schema.js"; +import { expandHome } from "./runtime.js"; +import { + DEFAULT_SCAN_AUTH, + DEFAULT_SCAN_MODE, + DEEP_SCAN_SETTINGS, + pickScanSettings, + type DeepScanOptions, + type ResolvedScanSettings, + type ScanSettings, +} from "./scan-settings.js"; +import { DiffTarget, type ScanTarget } from "./targets.js"; + +const validateProjectConfig = new Ajv({ + allErrors: true, +}).compile(projectConfigJsonSchema()); + +export interface ProjectConfigSource { + path?: string; + directory: string; + input: ProjectConfigInput; +} + +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: 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( + file: string, + directory = process.cwd(), +): 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 = projectConfigExtension(path); + 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]; + // Keep YAML's expansion guard while allowing repeated native profiles. + value = document.toJS({ maxAliasCount: 10_000 }); + } + } catch (error) { + throw new ConfigurationError( + `Cannot parse project configuration at ${path}.`, + { cause: error }, + ); + } + requireProjectConfig(value, path); + 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, + 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 [ + "# 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, +): asserts value is ProjectConfigInput { + 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${path === undefined ? "" : ` at ${path}`}: ${issues}`, + ); + } +} + +export function resolveScanSettings( + project: ProjectConfigSource | undefined, + overrides: Partial & { codexOverrides?: JsonObject }, + directory: string, + scopeSources: Partial> = {}, +): ResolvedProjectConfig { + const file = project?.input; + const sources: Partial> = {}; + const choose = ( + key: ProvenanceKey, + 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 projectDirectory = project?.directory ?? directory; + const filePath = (value: string | undefined): AbsolutePath | undefined => + value === undefined + ? undefined + : 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; + const explicitDeep = deepScanOptions({ ...overrides, mode }); + const target = + choose( + "scan.scope", + projectScopeTarget(file?.scan?.scope), + overrides.target, + ) ?? "repository"; + const configuredDeep = file?.scan?.deep; + const deep: DeepScanOptions = {}; + if (mode === "deep") { + for (const [name, , field] of DEEP_SCAN_SETTINGS) { + const value = choose( + `scan.deep.${field}`, + configuredDeep?.[field], + explicitDeep[name], + ); + if (value !== undefined) deep[name] = value; + } + } + + const codexOverrides = mergeCodexOverrides( + file?.codex ?? {}, + overrides.codexOverrides ?? {}, + ); + const recordNativeSources = ( + value: JsonObject, + source: ConfigurationSource, + prefix: "codex" | `codex.${string}` = "codex", + ) => { + for (const [key, item] of Object.entries(value)) { + 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) as ProvenanceKey[]) { + 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.knowledge_base", + file?.scan?.knowledge_base?.map((value) => + resolveConfigPath(projectDirectory, value), + ), + overrides.knowledgeBasePaths?.map((value) => + resolveConfigPath(directory, value), + ), + ) ?? []; + const settings: ResolvedScanSettings = { + ...pickScanSettings(overrides), + auth: choose("auth", file?.auth, overrides.auth) ?? DEFAULT_SCAN_AUTH, + mode, + target, + knowledgeBasePaths, + scanPromptFile: choose( + "scan.instructions_file", + filePath(file?.scan?.instructions_file), + cliPath(overrides.scanPromptFile), + ), + validationPromptFile: choose( + "scan.validation_file", + filePath(file?.scan?.validation_file), + cliPath(overrides.validationPromptFile), + ), + postScanPromptFile: cliPath(overrides.postScanPromptFile), + outputDir: choose( + "output.directory", + filePath(file?.output?.directory), + cliPath(overrides.outputDir), + ), + failureSeverity: choose( + "policy.fail_on_severity", + file?.policy?.fail_on_severity, + overrides.failureSeverity, + ), + maxCostUsd: choose( + "limits.max_cost_usd_per_scan", + file?.limits?.max_cost_usd_per_scan, + overrides.maxCostUsd, + ), + ...deep, + }; + const resolvedSources = configurationSources({ ...sources, ...scopeSources }); + return { + config: { codexOverrides }, + options: settings, + sources: resolvedSources, + ...(project?.path === undefined + ? {} + : { projectConfig: { path: project.path, sources: resolvedSources } }), + }; +} + +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.working_tree); +} diff --git a/sdk/typescript/src/prompt-files.ts b/sdk/typescript/src/prompt-files.ts new file mode 100644 index 000000000..9b4b0b171 --- /dev/null +++ b/sdk/typescript/src/prompt-files.ts @@ -0,0 +1,120 @@ +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"; + +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 | readonly 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, + // 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, + }; +} + +export async function readRegularInputFile( + path: string, + repository: string | readonly 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 canonicalParent = await realpath(dirname(path)); + 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)) { + 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..1fb5d8c19 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, severityThresholdRank } from "./scan-settings.js"; export interface TurnResultMetadata { id?: string; @@ -111,6 +113,13 @@ export class ScanResult { return join(this.scanDir, "artifacts"); } + public hasFindingsAtOrAbove(threshold: SeverityLevel): boolean { + severityThresholdRank(threshold); + return this.findings.findings.some((finding) => + meetsSeverity(finding, threshold), + ); + } + public toJSON(): Record { return { manifest: this.manifest, 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 new file mode 100644 index 000000000..9f46321d8 --- /dev/null +++ b/sdk/typescript/src/scan-settings.ts @@ -0,0 +1,172 @@ +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 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; + +// SDK name, legacy TOML key, project key, and CLI flag (when exposed). +export const DEEP_SCAN_SETTINGS = [ + ["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: positiveInteger.optional().meta({ + default: DEFAULT_DEEP_SCAN_SETTINGS.workers, + description: "Maximum concurrent deep-scan discovery workers.", + }), + subagents: nonnegativeInteger.optional().meta({ + default: DEFAULT_DEEP_SCAN_SETTINGS.subagents, + description: "Subagents available to each deep-scan worker. Zero is valid.", + }), + stopAfterNoNew: positiveInteger.optional().meta({ + default: DEFAULT_DEEP_SCAN_SETTINGS.stopAfterNoNew, + description: "Stop after this many runs find no new issues.", + }), + stopAfterConsecutiveErrors: positiveInteger.optional().meta({ + default: DEFAULT_DEEP_SCAN_SETTINGS.stopAfterConsecutiveErrors, + description: "Stop after this many consecutive discovery errors.", + }), + maxDiscoveryRuns: positiveInteger.optional().meta({ + default: DEFAULT_DEEP_SCAN_SETTINGS.maxDiscoveryRuns, + description: "Maximum deep-scan discovery runs.", + }), + 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; + +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: AbsolutePath[]; + scanPromptFile?: AbsolutePath; + validationPromptFile?: AbsolutePath; + postScanPromptFile?: AbsolutePath; + outputDir?: AbsolutePath; +} + +export type ScanPromptSettings = Pick< + ScanSettings, + | "scanPrompt" + | "scanPromptFile" + | "validationPrompt" + | "validationPromptFile" + | "postScanPrompt" + | "postScanPromptFile" +>; + +/** Pick defined scan settings without copying callbacks or workflow controls. */ +export function pickScanSettings(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 thresholdRank = severityThresholdRank(threshold); + const severity = SCAN_SEVERITIES.indexOf(finding.severity.level); + 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 237bbd3cc..96e76d960 100644 --- a/sdk/typescript/src/targets.ts +++ b/sdk/typescript/src/targets.ts @@ -8,6 +8,9 @@ import { InvalidTargetError } from "./errors.js"; import { resolveTrustedExecutable } from "./trusted-executable.js"; import { windowsUnsafePathComponent } from "./windows-path.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([ "GIT_DIR", @@ -30,7 +33,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 54252be8c..fbfea40a4 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, @@ -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 @@ -80,7 +81,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 +95,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 +123,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 +150,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 +167,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 +185,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); @@ -186,6 +207,60 @@ test.each(["completed", "receipt-lost", "scan-interrupted"])( }, ); +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", @@ -743,7 +818,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 +839,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", @@ -2338,10 +2430,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; }, @@ -2552,7 +2651,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 +2680,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 +2720,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 +2735,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 +2945,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"); @@ -2847,47 +2966,99 @@ 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", "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"); + 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 }); + if (settings !== "missing configuration") + 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"); - }, + await using client = new TestClient( + {}, + { + environment: { + CODEX_HOME: + homeKind === "relative" + ? relative(process.cwd(), codexHome) + : 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"); + 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, + }, + }); + } + } + }, + ); - 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"); @@ -2895,11 +3066,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() }, @@ -2921,8 +3095,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/cli-project-config.test.ts b/sdk/typescript/tests-ts/cli-project-config.test.ts new file mode 100644 index 000000000..a1d6a9cda --- /dev/null +++ b/sdk/typescript/tests-ts/cli-project-config.test.ts @@ -0,0 +1,1254 @@ +import { + mkdir, + mkdtemp, + readFile, + realpath, + rm, + symlink, + 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"; +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 { readProjectConfig } from "../src/project-config.js"; +import { + capture, + dependencies, + fakePreflight, + 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.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, modules) => { + 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: `${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.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", + scan: { + scope: { paths: ["src"] }, + knowledge_base: ["context.md"], + instructions_file: "linked/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); + 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(promptDirectory, "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", + scan: { + mode: "deep", + scope: { paths: ["src"] }, + deep: { + workers: 8, + subagents_per_worker: 0, + stop_after_consecutive_errors: 2, + }, + }, + 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; + 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"] }, + knowledge_base: ["file-context.md"], + deep: { workers: 8, subagents_per_worker: 3 }, + }, + limits: { max_cost_usd_per_scan: 7 }, + policy: { fail_on_severity: "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("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"] }, + ["--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" }, + ], + [ + { working_tree: {} }, + ["--base", "HEAD~1"], + { kind: "working_tree", base: "HEAD~1" }, + ], + [{ working_tree: {} }, ["--no-working-tree"], "repository"], +] as const)( + "resolves scope %j with overrides %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 deep mode"], + [ + ["--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, stop_after_consecutive_errors: 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: { instructions_file: "scan.md", validation_file: "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(["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"] }, + { 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, stop_after_consecutive_errors: 2 }, + }, + codex: { + profile: "review", + model: "gpt-5.6-sol", + profiles: { + review: { model: "gpt-5.6-terra", model_reasoning_effort: "high" }, + }, + }, + policy: { fail_on_severity: "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.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", + }, + }, + failOnSeverity: "high", + }); +}); + +test.each([ + [{ output: { directory: "../repository/artifacts" } }, "outside"], + [{ codex: { plugins: {} } }, "plugin"], + [{ scan: { mode: "deep", validation_file: "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", + CODEX_SECURITY_PROJECT_CONFIG: input.config, + }, + 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/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 dcb5d2545..c790a9613 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"; @@ -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)"); @@ -826,12 +825,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( @@ -2587,7 +2586,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, @@ -2722,37 +2724,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"], [ @@ -5065,7 +5064,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/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/deep-config.test.ts b/sdk/typescript/tests-ts/deep-config.test.ts new file mode 100644 index 000000000..ad39e922c --- /dev/null +++ b/sdk/typescript/tests-ts/deep-config.test.ts @@ -0,0 +1,272 @@ +import { + mkdir, + mkdtemp, + readFile, + realpath, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, 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.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', + ); + 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("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"], + ["[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"])); + 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("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", + ); + 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/multiscan.test.ts b/sdk/typescript/tests-ts/multiscan.test.ts index 8fd8ac9ba..77a4f3c4f 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,70 @@ 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) => { + 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 +284,9 @@ describe("multiscan", () => { scanPrompt: "Review boundaries.", postScanPrompt: "Draft confirmed fixes.", maxCostUsd: 12.5, + scanOptionsByMode: { + deep: { target: DiffTarget.workingTree() }, + }, }, ), ); 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..575de53d3 --- /dev/null +++ b/sdk/typescript/tests-ts/project-config.test.ts @@ -0,0 +1,417 @@ +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 { + readProjectConfig, + resolveScanSettings, +} 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, subagents_per_worker: 0, max_time_hours: 96 }, + }, + }, + true, + ], + [ + "working tree with an absent base", + { scan: { scope: { working_tree: {} } } }, + true, + ], + [ + "diff with an absent head", + { scan: { scope: { diff: { base: "HEAD" } } } }, + true, + ], + ["empty context list", { scan: { knowledge_base: [] } }, 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", 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: "." }, + 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: { subagents_per_worker: -1 } } }, + false, + ], + [ + "hours above the existing maximum", + { scan: { deep: { max_time_hours: 97 } } }, + false, + ], + ["nonpositive cost", { limits: { max_cost_usd_per_scan: 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: { subagents_per_worker: 0 } }, + codex: { synthetic_setting: "${LITERAL_VALUE}" }, + } satisfies ProjectConfigInput; + await writeFile( + yaml, + "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); + expect((await readProjectConfig(json)).input).toEqual(input); + }); + + 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, "nested-aliases.yaml"); + await writeFile( + path, + [ + "codex:", + " 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"), + ); + await expect(readProjectConfig(path)).rejects.toThrow( + "Cannot parse project configuration", + ); + }); + + 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(readProjectConfig(path)).rejects.toThrow(); + }); + + test("reports a missing selected file", async () => { + await expect( + readProjectConfig("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"), + directory: join(root, "settings"), + input: { + scan: { + scope: { paths: ["src"] }, + knowledge_base: ["context.md"], + instructions_file: "scan.md", + validation_file: "validate.md", + }, + output: { directory: "../artifacts" }, + } satisfies ProjectConfigInput, + }; + const { options: settings, projectConfig: provenance } = + resolveScanSettings( + project, + { + knowledgeBasePaths: ["cli-context.md"], + validationPromptFile: "cli-validate.md", + }, + join(root, "invocation"), + ); + expect(settings).toMatchObject({ + target: ["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.knowledge_base": "cli", + "scan.instructions_file": "project", + "scan.validation_file": "cli", + "output.directory": "project", + }); + expect(project.input.scan.knowledge_base).toEqual(["context.md"]); + }); + + 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: { subagents_per_worker: 3, workers: 8 } }, + codex: { + profile: "reviewCase", + profiles: { + reviewCase: { + model: "gpt-5.6-terra", + model_reasoning_effort: "high", + }, + }, + synthetic_setting: { enabled: true, itemCount: 2, names: ["first"] }, + }, + } satisfies ProjectConfigInput, + }; + const { + config, + options: settings, + projectConfig: provenance, + } = resolveScanSettings( + project, + { + subagents: 0, + codexOverrides: { + model: "gpt-5.6-sol", + synthetic_setting: { enabled: false, itemCount: 0, names: [] }, + }, + }, + root, + ); + expect(settings).toMatchObject({ + subagents: 0, + workers: 8, + }); + expect(config).toEqual({ + codexOverrides: { + model: "gpt-5.6-sol", + profile: "reviewCase", + profiles: project.input.codex.profiles, + synthetic_setting: { enabled: false, itemCount: 0, names: [] }, + }, + }); + expect(provenance?.sources).toMatchObject({ + "scan.deep.subagents_per_worker": "cli", + "scan.deep.workers": "project", + "codex.model": "cli", + "codex.profiles.reviewCase.model": "project", + "codex.synthetic_setting.itemCount": "cli", + }); + }); + + 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"), + directory: root, + input: { + scan: { mode: "deep", deep: { workers: 8 } }, + } satisfies ProjectConfigInput, + }; + expect( + resolveScanSettings(project, { mode: "standard" }, root).options.workers, + ).toBeUndefined(); + expect(() => + resolveScanSettings(project, { mode: "standard", workers: 2 }, root), + ).toThrow("require deep mode"); + }); + + 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 readProjectConfig(filename, root); + expect(() => resolveScanSettings(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(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..935459e68 100644 --- a/sdk/typescript/tests-ts/result.test.ts +++ b/sdk/typescript/tests-ts/result.test.ts @@ -3,11 +3,13 @@ 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, RepositoryFinding, ScanManifest, + SeverityLevel, } from "../src/index.js"; const manifest = { @@ -50,6 +52,31 @@ 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(); + 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..63421e482 --- /dev/null +++ b/sdk/typescript/tests-ts/sdk-project-config.test.ts @@ -0,0 +1,252 @@ +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 input = { + auth: "api-key", + scan: { + mode, + scope: { paths: ["src"] }, + 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: { max_cost_usd_per_scan: 5 }, + policy: { fail_on_severity: "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" })], + [{ working_tree: {} }, 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: { 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"); + 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..2cedbb360 --- /dev/null +++ b/sdk/typescript/tests-ts/sdk-scan-prompts.test.ts @@ -0,0 +1,180 @@ +import { mkdir, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, expect, test } from "bun:test"; +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, 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"], + ["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"); +}); diff --git a/sdk/typescript/tests-ts/skeleton.test.ts b/sdk/typescript/tests-ts/skeleton.test.ts index 5636217c8..f256e69cd 100644 --- a/sdk/typescript/tests-ts/skeleton.test.ts +++ b/sdk/typescript/tests-ts/skeleton.test.ts @@ -357,8 +357,10 @@ 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).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",