diff --git a/.github/workflows/codex-security.yml b/.github/workflows/codex-security.yml index 0749ec7d84..a008af019f 100644 --- a/.github/workflows/codex-security.yml +++ b/.github/workflows/codex-security.yml @@ -49,6 +49,11 @@ on: description: Optional previous stable tag override required: false type: string + upload_sarif: + description: Upload manual-run results to Code Scanning + required: false + default: false + type: boolean allow_full_bootstrap: description: Allow a full scan when no previous stable tag exists required: false @@ -65,6 +70,7 @@ concurrency: cancel-in-progress: true env: + CODEX_SECURITY_MAX_CONCURRENT_THREADS: "8" CODEX_SECURITY_REASONING_EFFORT: medium NVIDIA_INFERENCE_BASE_URL: https://inference-api.nvidia.com/v1 NVIDIA_INFERENCE_MODEL: openai/openai/gpt-5.6-sol @@ -72,6 +78,8 @@ env: jobs: analyze: name: Codex Security (${{ inputs.candidate_ref || github.ref_name }}) + # The agent executes no shell commands on the repository self-hosted runner, + # so its preflight never scopes the diff and it seals no draft. runs-on: ubuntu-latest timeout-minutes: 120 outputs: @@ -93,6 +101,17 @@ jobs: with: python-version: "3.14" + # Codex confines model-run commands with bubblewrap, which needs + # unprivileged user namespaces. Ubuntu 24.04 restricts those through + # AppArmor, so bubblewrap cannot set up the sandbox network namespace and + # the scan agent executes nothing at all. + - name: Allow unprivileged user namespaces + run: | + set -euo pipefail + if [ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + fi + - name: Install Codex Security run: | set -euo pipefail @@ -111,6 +130,25 @@ jobs: test -x "$CODEX_SECURITY_BIN" "$CODEX_SECURITY_BIN" --version + # The range resolver has to come from the workflow's own revision. A + # scanned candidate predates it, and running the resolver from the + # revision under scan would let that revision pick its own scan range. + - name: Check out the workflow revision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: workflow-revision + sparse-checkout: tasks/scripts + persist-credentials: false + + - name: Stage the range resolver + run: | + set -euo pipefail + install -d -m 700 "$RUNNER_TEMP/range-resolver" + cp workflow-revision/tasks/scripts/release.py \ + workflow-revision/tasks/scripts/codex_security_range.py \ + "$RUNNER_TEMP/range-resolver/" + rm -rf workflow-revision + - name: Check out the pre-release uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -139,17 +177,17 @@ jobs: args+=(--allow-full-bootstrap) fi - node tasks/scripts/codex-security-release-range.mjs "${args[@]}" + python3 "$RUNNER_TEMP/range-resolver/codex_security_range.py" "${args[@]}" - name: Scan changes since the previous stable env: BASE_SHA: ${{ steps.range.outputs.base_sha }} CODEX_SECURITY_BIN: ${{ runner.temp }}/codex-security/node_modules/.bin/codex-security - CODEX_SECURITY_STATE_DIR: ${{ runner.temp }}/codex-security-state + CODEX_SECURITY_STATE_DIR: ${{ runner.temp }}/codex-security-state-${{ github.run_id }}-${{ github.run_attempt }} HEAD_SHA: ${{ steps.range.outputs.candidate_sha }} NVIDIA_INFERENCE_API_KEY: ${{ secrets.CODEX_SECURITY_API_KEY }} OPENAI_API_KEY: ${{ secrets.CODEX_SECURITY_API_KEY }} - SCAN_DIR: ${{ runner.temp }}/codex-security-results + SCAN_DIR: ${{ runner.temp }}/codex-security-results-${{ github.run_id }}-${{ github.run_attempt }} SCAN_SCOPE: ${{ steps.range.outputs.scan_scope }} run: | set -euo pipefail @@ -174,6 +212,8 @@ jobs: --codex 'model_providers.nvidia.env_key="NVIDIA_INFERENCE_API_KEY"' \ --codex 'model_providers.nvidia.wire_api="responses"' \ --codex 'model_providers.nvidia.supports_websockets=false' \ + --codex "features.multi_agent_v2.max_concurrent_threads_per_session=$CODEX_SECURITY_MAX_CONCURRENT_THREADS" \ + --codex 'approval_policy="never"' \ --output-dir "$SCAN_DIR" \ --headless > /dev/null @@ -181,7 +221,7 @@ jobs: env: CODEX_SECURITY_BIN: ${{ runner.temp }}/codex-security/node_modules/.bin/codex-security SARIF_FILE: ${{ runner.temp }}/codex-security.sarif - SCAN_DIR: ${{ runner.temp }}/codex-security-results + SCAN_DIR: ${{ runner.temp }}/codex-security-results-${{ github.run_id }}-${{ github.run_attempt }} run: | set -euo pipefail "$CODEX_SECURITY_BIN" export "$SCAN_DIR" \ @@ -205,6 +245,7 @@ jobs: echo echo "- Train: \`$TRAIN\`" echo "- Candidate: \`$CANDIDATE_TAG\`" + echo "- Maximum concurrent agent threads: $CODEX_SECURITY_MAX_CONCURRENT_THREADS" if [ "$SCAN_SCOPE" = "diff" ]; then echo "- Previous stable: \`$BASE_TAG\`" echo "- Commits in cumulative diff: $COMMIT_COUNT" @@ -218,6 +259,7 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - name: Upload SARIF to Code Scanning + if: ${{ github.event_name != 'workflow_dispatch' || inputs.upload_sarif }} uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: sarif_file: ${{ runner.temp }}/codex-security.sarif @@ -227,7 +269,7 @@ jobs: result: name: OpenShell / Codex Security (informational) - if: always() + if: ${{ always() }} needs: analyze runs-on: ubuntu-latest permissions: {} diff --git a/architecture/build.md b/architecture/build.md index 393eb9e468..d45cc25666 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -288,7 +288,7 @@ the release tag. ## CI and E2E -Required checks run on GitHub Actions. Workflows that use NVIDIA self-hosted runners trigger from copy-pr-bot mirror branches, so trusted PRs are mirrored into `pull-request/` branches before those workflows run. `main` also uses GitHub merge queue so the final queued integration commit is validated before it merges. +Required checks run on GitHub Actions. Pull-request workflows that use NVIDIA self-hosted runners trigger from copy-pr-bot mirror branches, so trusted PRs are mirrored into `pull-request/` branches before those workflows run. `main` also uses GitHub merge queue so the final queued integration commit is validated before it merges. The high-level CI model: @@ -307,16 +307,19 @@ synthetic activity from contributing to product usage metrics. Static security checks are deliberately outside the mirror-branch path. They run directly on GitHub-hosted runners and none of them consume NVIDIA self-hosted capacity. The change-oriented ones receive no secrets, so they also cover fork -pull requests; Codex Security release qualification is the exception because it -needs a scoped API key. That key routes Codex Security's model calls to -NVIDIA-hosted inference; the job itself still runs on a GitHub-hosted runner and -uses no NVIDIA self-hosted runner. Scanner jobs request `security-events: write` -and upload SARIF to Code Scanning directly on every event they run on, including -fork and Dependabot pull requests, which Code Scanning permits for +pull requests. Codex Security release qualification is the exception: it needs a +scoped API key, which routes its model calls to NVIDIA-hosted inference while +the job itself stays GitHub-hosted. That placement is load-bearing rather than +incidental: on the repository self-hosted runner the scan agent executes no +shell commands at all, so its preflight never scopes the diff and it seals no +draft. Scanner jobs request `security-events: write` and upload SARIF to Code +Scanning directly on every event they run on, including fork and Dependabot +pull requests, which Code Scanning permits for `pull_request` runs despite their read-only `GITHUB_TOKEN`. No privileged -intermediate workflow relays those uploads. Report retention differs by scanner: -Actionlint, Zizmor, and CodeQL keep their reports as workflow artifacts, and -Codex Security keeps no raw report. +intermediate workflow relays those uploads. Manually dispatched Codex Security +runs are the one opt-in exception, described below. Report retention differs by +scanner: Actionlint, Zizmor, and CodeQL keep their reports as workflow artifacts, +and Codex Security keeps no raw report. Triggers differ by workflow: `.github/workflows/workflow-security.yml` runs on `pull_request`, `merge_group`, `main`, and a weekly schedule; `.github/workflows/dependency-review.yml` runs on `pull_request` and @@ -361,11 +364,23 @@ a pull request or merge group. calls go to NVIDIA-hosted inference at `https://inference-api.nvidia.com/v1`, declared as a custom Codex provider named `nvidia` that uses the Responses wire API with WebSockets disabled. The scan runs `openai/openai/gpt-5.6-sol` - at `medium` reasoning effort. The `CODEX_SECURITY_API_KEY` secret holds the + at `medium` reasoning effort, with the multi-agent runtime capped at eight + concurrent threads through + `features.multi_agent_v2.max_concurrent_threads_per_session`. The + `CODEX_SECURITY_API_KEY` secret holds the NVIDIA key and is exposed to the scan step alone, as `OPENAI_API_KEY` so the CLI selects API-key auth and as `NVIDIA_INFERENCE_API_KEY`, the provider - `env_key` read by the Codex child process. - `tasks/scripts/codex-security-release-range.mjs` resolves the scan range: the + `env_key` read by the Codex child process. `CODEX_SECURITY_STATE_DIR` and + `SCAN_DIR` are suffixed with `github.run_id` and `github.run_attempt` and + created mode `700`, so no scanner state or result set from a previous run or + retry attempt is reused even on a runner with a reusable temp directory. + `tasks/scripts/codex_security_range.py` resolves the scan range, reusing the + tag parsers in `tasks/scripts/release.py` so both stay on one definition of a + release tag while requiring the `v` prefix that a release workflow needs. The + job stages both files out of the workspace from the workflow's own revision + and runs the resolver by absolute path, because a scanned candidate predates + them and a revision under scan must not choose its own scan range. The range + itself is resolved against the checked-out candidate: the candidate must be a `vX.Y.Z-pre.N` tag that is an ancestor of `origin/main`, and the base is the newest stable `vX.Y.Z` tag merged into the candidate that is strictly older than the release train `vX.Y.Z` the candidate targets. A @@ -374,12 +389,38 @@ a pull request or merge group. stable-to-candidate diff, so later candidates re-cover earlier ones. SARIF is uploaded against `refs/heads/main` at the candidate commit under the train-scoped category `codex-security/vX.Y.Z`, which makes each candidate's - analysis replace the previous one for that train. Codex Security 0.1.24 cannot - apply `--max-cost` to a slash-qualified model identifier, so the run has no - CLI-enforced cost ceiling. Spend is bounded instead by the 120-minute job - timeout, a single repository-wide concurrency group that serializes + analysis replace the previous one for that train. Automatic pre-release tag + pushes and `workflow_call` runs always upload. `workflow_dispatch` runs still + perform the scan and the SARIF export, but skip the Code Scanning upload + unless the caller sets the `upload_sarif` input, so manual diagnostics do not + overwrite a train's published analysis by default. Codex Security 0.1.24 + cannot apply `--max-cost` to a slash-qualified model identifier, so the run + has no CLI-enforced cost ceiling. Spend is bounded instead by the 120-minute + job timeout, a single repository-wide concurrency group that serializes qualification so starting a newer candidate cancels an in-flight one, and NVIDIA account-side controls. No raw report is retained. +- The job clears `kernel.apparmor_restrict_unprivileged_userns` before + installing the scanner. Codex confines model-run commands with bubblewrap, + which needs unprivileged user namespaces; Ubuntu 24.04 restricts those through + AppArmor, so bubblewrap fails to configure the sandbox network namespace + (`bwrap: loopback: Failed RTM_NEWADDR`) and the agent executes no commands at + all. The failure is silent: the agent retries its shell tool, gives up, and + seals no draft, while the scanner only reports a missing or incomplete draft. + Lifting a kernel restriction on the runner is what allows the sandbox that + confines the agent to start, and the runner is ephemeral and GitHub-hosted. +- The scan sets `approval_policy="never"`. Codex Security keeps + `approvals_reviewer="auto_review"` unconditionally, and that reviewer runs on + its own model rather than the configured one. Because the workflow declares a + single provider that serves only `openai/openai/gpt-5.6-sol`, any approval + request reaches a model the endpoint does not serve, so the agent never gets a + shell command approved and seals no draft. The scan stays confined by its + `workspace-write` sandbox with network access disabled and by the scanner's + own permission profile, which grants read access to the filesystem root and + write access only to the workspace roots. +- A scan that cannot execute commands reports only a missing or incomplete + draft, so diagnosing one means reading the scanner's session rollouts under + `CODEX_SECURITY_STATE_DIR`, where every shell command the agent ran is + recorded. No command at all is the signal that the sandbox failed to start. Findings never fail these checks; scanner and build failures do. A scanner that cannot run, a CodeQL analyzer that does not complete, an unexpected Dependency diff --git a/tasks/scripts/codex-security-release-range.mjs b/tasks/scripts/codex-security-release-range.mjs deleted file mode 100644 index 025df1e7ad..0000000000 --- a/tasks/scripts/codex-security-release-range.mjs +++ /dev/null @@ -1,232 +0,0 @@ -#!/usr/bin/env node -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { execFileSync, spawnSync } from 'node:child_process'; -import { appendFileSync } from 'node:fs'; - -const STABLE_TAG_RE = - /^v(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)$/; -const PRERELEASE_TAG_RE = - /^v(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)-pre\.(?[1-9]\d*)$/; - -function versionFromMatch(match) { - return [ - Number(match.groups.major), - Number(match.groups.minor), - Number(match.groups.patch), - ]; -} - -export function parseStableTag(tag) { - const match = STABLE_TAG_RE.exec(tag); - if (match === null) return null; - return { tag, version: versionFromMatch(match) }; -} - -export function parsePrereleaseTag(tag) { - const match = PRERELEASE_TAG_RE.exec(tag); - if (match === null) return null; - const version = versionFromMatch(match); - return { - tag, - version, - prerelease: Number(match.groups.prerelease), - train: `v${version.join('.')}`, - }; -} - -export function compareVersions(left, right) { - for (let index = 0; index < left.length; index += 1) { - if (left[index] !== right[index]) return left[index] - right[index]; - } - return 0; -} - -export function selectPreviousStable(tags, candidateVersion) { - return tags - .map(parseStableTag) - .filter( - (parsed) => - parsed !== null && - compareVersions(parsed.version, candidateVersion) < 0, - ) - .sort((left, right) => compareVersions(right.version, left.version))[0]?.tag; -} - -function parseArguments(argv) { - const options = { - candidate: '', - stable: '', - mainRef: 'origin/main', - allowFullBootstrap: false, - githubOutput: process.env.GITHUB_OUTPUT ?? '', - }; - - for (let index = 0; index < argv.length; index += 1) { - const argument = argv[index]; - switch (argument) { - case '--candidate': - options.candidate = argv[++index] ?? ''; - break; - case '--stable': - options.stable = argv[++index] ?? ''; - break; - case '--main-ref': - options.mainRef = argv[++index] ?? ''; - break; - case '--allow-full-bootstrap': - options.allowFullBootstrap = true; - break; - case '--github-output': - options.githubOutput = argv[++index] ?? ''; - break; - default: - throw new Error(`unknown argument: ${argument}`); - } - } - - if (options.candidate === '') { - throw new Error('--candidate is required'); - } - if (options.mainRef === '') { - throw new Error('--main-ref must not be empty'); - } - return options; -} - -function git(args) { - return execFileSync('git', args, { encoding: 'utf8' }).trim(); -} - -function resolvesToCommit(ref) { - try { - return git(['rev-parse', '--verify', `${ref}^{commit}`]); - } catch { - throw new Error(`Git reference does not resolve to a commit: ${ref}`); - } -} - -function isAncestor(ancestor, descendant) { - const result = spawnSync( - 'git', - ['merge-base', '--is-ancestor', ancestor, descendant], - { stdio: 'ignore' }, - ); - if (result.status === 0) return true; - if (result.status === 1) return false; - throw new Error( - `git merge-base failed for ${ancestor} and ${descendant} (exit ${result.status ?? 'unknown'})`, - ); -} - -function writeOutputs(path, outputs) { - if (path === '') return; - const lines = Object.entries(outputs).map(([key, value]) => `${key}=${value}`); - appendFileSync(path, `${lines.join('\n')}\n`, { encoding: 'utf8' }); -} - -function resolveRange(options) { - const candidate = parsePrereleaseTag(options.candidate); - if (candidate === null) { - throw new Error( - `candidate must match vMAJOR.MINOR.PATCH-pre.N: ${options.candidate}`, - ); - } - - const candidateSha = resolvesToCommit(candidate.tag); - const mainSha = resolvesToCommit(options.mainRef); - if (!isAncestor(candidateSha, mainSha)) { - throw new Error( - `candidate ${candidate.tag} (${candidateSha}) is not an ancestor of ${options.mainRef}`, - ); - } - - const mergedTags = git(['tag', '--list', 'v*', '--merged', candidateSha]) - .split('\n') - .filter(Boolean); - const stableTag = - options.stable || selectPreviousStable(mergedTags, candidate.version); - - if (stableTag === undefined || stableTag === '') { - if (!options.allowFullBootstrap) { - throw new Error( - `no previous stable tag exists for ${candidate.tag}; rerun with an approved base or --allow-full-bootstrap`, - ); - } - - return { - base_tag: '', - base_sha: '', - candidate_tag: candidate.tag, - candidate_sha: candidateSha, - train: candidate.train, - category: `codex-security/${candidate.train}`, - scan_scope: 'full', - commit_count: git(['rev-list', '--count', candidateSha]), - }; - } - - const stable = parseStableTag(stableTag); - if (stable === null) { - throw new Error( - `stable base must match vMAJOR.MINOR.PATCH without a prerelease: ${stableTag}`, - ); - } - if (compareVersions(stable.version, candidate.version) >= 0) { - throw new Error( - `stable base ${stable.tag} must be older than release train ${candidate.train}`, - ); - } - - const stableSha = resolvesToCommit(stable.tag); - if (!isAncestor(stableSha, candidateSha)) { - throw new Error( - `stable base ${stable.tag} (${stableSha}) is not an ancestor of ${candidate.tag}`, - ); - } - - const commitCount = Number( - git(['rev-list', '--count', `${stableSha}..${candidateSha}`]), - ); - if (!Number.isSafeInteger(commitCount) || commitCount <= 0) { - throw new Error( - `candidate ${candidate.tag} has no commits after stable base ${stable.tag}`, - ); - } - - return { - base_tag: stable.tag, - base_sha: stableSha, - candidate_tag: candidate.tag, - candidate_sha: candidateSha, - train: candidate.train, - category: `codex-security/${candidate.train}`, - scan_scope: 'diff', - commit_count: String(commitCount), - }; -} - -function main() { - try { - const options = parseArguments(process.argv.slice(2)); - const outputs = resolveRange(options); - writeOutputs(options.githubOutput, outputs); - process.stdout.write(`${JSON.stringify(outputs, null, 2)}\n`); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (process.env.GITHUB_ACTIONS === 'true') { - process.stderr.write(`::error::${message}\n`); - } else { - process.stderr.write(`codex-security-release-range: ${message}\n`); - } - process.exitCode = 1; - } -} - -if ( - process.argv[1] !== undefined && - import.meta.url === new URL(process.argv[1], 'file:').href -) { - main(); -} diff --git a/tasks/scripts/codex-security-release-range.test.mjs b/tasks/scripts/codex-security-release-range.test.mjs deleted file mode 100644 index ea017a8825..0000000000 --- a/tasks/scripts/codex-security-release-range.test.mjs +++ /dev/null @@ -1,190 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import assert from 'node:assert/strict'; -import { execFileSync, spawnSync } from 'node:child_process'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import test from 'node:test'; - -import { - compareVersions, - parsePrereleaseTag, - parseStableTag, - selectPreviousStable, -} from './codex-security-release-range.mjs'; - -const SCRIPT = fileURLToPath( - new URL('./codex-security-release-range.mjs', import.meta.url), -); - -function git(repository, ...args) { - return execFileSync('git', args, { - cwd: repository, - encoding: 'utf8', - }).trim(); -} - -function commit(repository, name) { - writeFileSync(join(repository, 'content.txt'), `${name}\n`, { - encoding: 'utf8', - }); - git(repository, 'add', 'content.txt'); - git( - repository, - '-c', - 'user.name=Codex Security Test', - '-c', - 'user.email=codex-security-test@example.com', - 'commit', - '-m', - name, - ); -} - -function createRepository() { - const repository = mkdtempSync(join(tmpdir(), 'codex-security-range-')); - git(repository, 'init', '--initial-branch=main'); - return repository; -} - -function runResolver(repository, ...args) { - return JSON.parse( - execFileSync(process.execPath, [SCRIPT, ...args], { - cwd: repository, - encoding: 'utf8', - }), - ); -} - -test('parses strict stable and prerelease tags', () => { - assert.deepEqual(parseStableTag('v0.1.0'), { - tag: 'v0.1.0', - version: [0, 1, 0], - }); - assert.equal(parseStableTag('v0.1.0-pre.1'), null); - assert.equal(parseStableTag('dev'), null); - assert.equal(parseStableTag('v01.1.0'), null); - - assert.deepEqual(parsePrereleaseTag('v2.10.3-pre.12'), { - tag: 'v2.10.3-pre.12', - version: [2, 10, 3], - prerelease: 12, - train: 'v2.10.3', - }); - assert.equal(parsePrereleaseTag('v2.10.3'), null); - assert.equal(parsePrereleaseTag('v2.10.3-pre.0'), null); -}); - -test('selects the newest stable strictly before the candidate train', () => { - assert.equal( - selectPreviousStable( - [ - 'v0.1.9', - 'v0.1.10', - 'v0.2.0-pre.1', - 'v0.2.0', - 'vm-runtime', - ], - [0, 2, 0], - ), - 'v0.1.10', - ); - assert.equal(selectPreviousStable(['v0.1.0'], [0, 1, 0]), undefined); - assert(compareVersions([0, 10, 0], [0, 2, 99]) > 0); -}); - -test('resolves a cumulative prerelease range from Git history', () => { - const repository = createRepository(); - try { - commit(repository, 'stable'); - git(repository, 'tag', 'v0.1.0'); - commit(repository, 'pre one'); - git(repository, 'tag', 'v0.1.1-pre.1'); - commit(repository, 'pre two'); - git(repository, 'tag', 'v0.1.1-pre.2'); - git( - repository, - 'update-ref', - 'refs/remotes/origin/main', - git(repository, 'rev-parse', 'HEAD'), - ); - - const result = runResolver( - repository, - '--candidate', - 'v0.1.1-pre.2', - ); - assert.equal(result.base_tag, 'v0.1.0'); - assert.equal(result.candidate_tag, 'v0.1.1-pre.2'); - assert.equal(result.train, 'v0.1.1'); - assert.equal(result.category, 'codex-security/v0.1.1'); - assert.equal(result.scan_scope, 'diff'); - assert.equal(result.commit_count, '2'); - } finally { - rmSync(repository, { recursive: true, force: true }); - } -}); - -test('rejects a prerelease that is not on main', () => { - const repository = createRepository(); - try { - commit(repository, 'stable'); - git(repository, 'tag', 'v0.1.0'); - git( - repository, - 'update-ref', - 'refs/remotes/origin/main', - git(repository, 'rev-parse', 'HEAD'), - ); - git(repository, 'switch', '--create', 'detached-release'); - commit(repository, 'off-main candidate'); - git(repository, 'tag', 'v0.1.1-pre.1'); - - const result = spawnSync( - process.execPath, - [SCRIPT, '--candidate', 'v0.1.1-pre.1'], - { cwd: repository, encoding: 'utf8' }, - ); - assert.equal(result.status, 1); - assert.match(result.stderr, /is not an ancestor of origin\/main/); - } finally { - rmSync(repository, { recursive: true, force: true }); - } -}); - -test('requires explicit approval before a full bootstrap scan', () => { - const repository = createRepository(); - try { - commit(repository, 'first candidate'); - git(repository, 'tag', 'v0.1.0-pre.1'); - git( - repository, - 'update-ref', - 'refs/remotes/origin/main', - git(repository, 'rev-parse', 'HEAD'), - ); - - const rejected = spawnSync( - process.execPath, - [SCRIPT, '--candidate', 'v0.1.0-pre.1'], - { cwd: repository, encoding: 'utf8' }, - ); - assert.equal(rejected.status, 1); - assert.match(rejected.stderr, /--allow-full-bootstrap/); - - const approved = runResolver( - repository, - '--candidate', - 'v0.1.0-pre.1', - '--allow-full-bootstrap', - ); - assert.equal(approved.scan_scope, 'full'); - assert.equal(approved.base_tag, ''); - assert.equal(approved.train, 'v0.1.0'); - } finally { - rmSync(repository, { recursive: true, force: true }); - } -}); diff --git a/tasks/scripts/codex_security_range.py b/tasks/scripts/codex_security_range.py new file mode 100644 index 0000000000..845145b90f --- /dev/null +++ b/tasks/scripts/codex_security_range.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Resolve the Codex Security scan range for a pre-release candidate. + +Tag parsing is reused from release.py so both stay on one definition of what a +release tag is. That module also accepts tags without the `v` prefix, which a +release workflow must not, so the prefix is required here. +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from release import ( + _format_semver, + _parse_prerelease_tag, + _parse_semver_tag, +) + + +class ReleaseRangeError(Exception): + """A scan range could not be resolved from the supplied refs.""" + + +def _git(repo: Path, cmd: list[str]) -> str: + return subprocess.check_output(["git", *cmd], cwd=repo).decode("utf-8").strip() + + +def _resolve_commit(repo: Path, ref: str) -> str: + try: + return _git(repo, ["rev-parse", "--verify", f"{ref}^{{commit}}"]) + except subprocess.CalledProcessError as error: + raise ReleaseRangeError( + f"Git reference does not resolve to a commit: {ref}" + ) from error + + +def _is_ancestor(repo: Path, ancestor: str, descendant: str) -> bool: + result = subprocess.run( + ["git", "merge-base", "--is-ancestor", ancestor, descendant], + cwd=repo, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + if result.returncode in (0, 1): + return result.returncode == 0 + raise ReleaseRangeError( + f"git merge-base failed for {ancestor} and {descendant} " + f"(exit {result.returncode})" + ) + + +def parse_stable_tag(tag: str) -> tuple[int, int, int] | None: + return _parse_semver_tag(tag) if tag.startswith("v") else None + + +def parse_prerelease_tag(tag: str) -> tuple[int, int, int, int] | None: + return _parse_prerelease_tag(tag) if tag.startswith("v") else None + + +def select_previous_stable( + tags: list[str], candidate_version: tuple[int, int, int] +) -> str | None: + older = [ + (version, tag) + for tag in tags + if (version := parse_stable_tag(tag)) and version < candidate_version + ] + return max(older)[1] if older else None + + +def resolve_range( + *, + repo: Path, + candidate: str, + stable: str = "", + main_ref: str = "origin/main", + allow_full_bootstrap: bool = False, +) -> dict[str, str]: + parsed_candidate = parse_prerelease_tag(candidate) + if parsed_candidate is None: + raise ReleaseRangeError( + f"candidate must match vMAJOR.MINOR.PATCH-pre.N: {candidate}" + ) + candidate_version = parsed_candidate[:3] + train = f"v{_format_semver(candidate_version)}" + + candidate_sha = _resolve_commit(repo, candidate) + main_sha = _resolve_commit(repo, main_ref) + if not _is_ancestor(repo, candidate_sha, main_sha): + raise ReleaseRangeError( + f"candidate {candidate} ({candidate_sha}) is not an ancestor of {main_ref}" + ) + + resolved = { + "candidate_tag": candidate, + "candidate_sha": candidate_sha, + "train": train, + "category": f"codex-security/{train}", + } + + merged_tags = [ + tag + for tag in _git( + repo, ["tag", "--list", "v*", "--merged", candidate_sha] + ).splitlines() + if tag + ] + stable_tag = stable or select_previous_stable(merged_tags, candidate_version) + if not stable_tag: + if not allow_full_bootstrap: + raise ReleaseRangeError( + f"no previous stable tag exists for {candidate}; rerun with an " + "approved base or --allow-full-bootstrap" + ) + return { + **resolved, + "base_tag": "", + "base_sha": "", + "scan_scope": "full", + "commit_count": _git(repo, ["rev-list", "--count", candidate_sha]), + } + + stable_version = parse_stable_tag(stable_tag) + if stable_version is None: + raise ReleaseRangeError( + "stable base must match vMAJOR.MINOR.PATCH without a prerelease: " + f"{stable_tag}" + ) + if stable_version >= candidate_version: + raise ReleaseRangeError( + f"stable base {stable_tag} must be older than release train {train}" + ) + + stable_sha = _resolve_commit(repo, stable_tag) + if not _is_ancestor(repo, stable_sha, candidate_sha): + raise ReleaseRangeError( + f"stable base {stable_tag} ({stable_sha}) is not an ancestor of {candidate}" + ) + + commit_count = int( + _git(repo, ["rev-list", "--count", f"{stable_sha}..{candidate_sha}"]) + ) + if commit_count <= 0: + raise ReleaseRangeError( + f"candidate {candidate} has no commits after stable base {stable_tag}" + ) + + return { + **resolved, + "base_tag": stable_tag, + "base_sha": stable_sha, + "scan_scope": "diff", + "commit_count": str(commit_count), + } + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Resolve the Codex Security scan range." + ) + parser.add_argument( + "--candidate", required=True, help="Pre-release tag (vMAJOR.MINOR.PATCH-pre.N)." + ) + parser.add_argument("--stable", default="", help="Previous stable tag override.") + parser.add_argument( + "--main-ref", + default="origin/main", + help="Ref the candidate must be an ancestor of.", + ) + parser.add_argument( + "--allow-full-bootstrap", + action="store_true", + help="Allow a full scan when no previous stable tag exists.", + ) + parser.add_argument( + "--github-output", + type=Path, + default=None, + help="File to append key=value outputs to (default: $GITHUB_OUTPUT).", + ) + return parser + + +def main() -> None: + args = build_parser().parse_args() + + try: + outputs = resolve_range( + repo=Path.cwd(), + candidate=args.candidate, + stable=args.stable, + main_ref=args.main_ref, + allow_full_bootstrap=args.allow_full_bootstrap, + ) + except ReleaseRangeError as error: + if os.environ.get("GITHUB_ACTIONS") == "true": + print(f"::error::{error}", file=sys.stderr) + else: + print(f"codex-security-range: {error}", file=sys.stderr) + raise SystemExit(1) from error + + environment_output = os.environ.get("GITHUB_OUTPUT") + output_path = args.github_output or ( + Path(environment_output) if environment_output else None + ) + if output_path is not None: + with output_path.open("a", encoding="utf-8") as handle: + for key, value in outputs.items(): + handle.write(f"{key}={value}\n") + print(json.dumps(outputs, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tasks/scripts/codex_security_range_test.py b/tasks/scripts/codex_security_range_test.py new file mode 100644 index 0000000000..dd77fe7693 --- /dev/null +++ b/tasks/scripts/codex_security_range_test.py @@ -0,0 +1,177 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import codex_security_range as ranges + +SCRIPT = Path(__file__).resolve().parent / "codex_security_range.py" + + +def _git(repo: Path, *args: str) -> str: + return subprocess.check_output(["git", *args], cwd=repo).decode("utf-8").strip() + + +def _commit(repo: Path, name: str) -> None: + (repo / "content.txt").write_text(f"{name}\n", encoding="utf-8") + _git(repo, "add", "content.txt") + _git( + repo, + "-c", + "user.name=Codex Security Test", + "-c", + "user.email=codex-security-test@example.com", + "commit", + "-m", + name, + ) + + +def _publish_main(repo: Path) -> None: + _git( + repo, "update-ref", "refs/remotes/origin/main", _git(repo, "rev-parse", "HEAD") + ) + + +def _run(repo: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(SCRIPT), *args], + cwd=repo, + capture_output=True, + text=True, + check=False, + ) + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + _git(tmp_path, "init", "--initial-branch=main") + return tmp_path + + +def test_parses_strict_stable_and_prerelease_tags() -> None: + assert ranges.parse_stable_tag("v0.1.0") == (0, 1, 0) + assert ranges.parse_stable_tag("v0.1.0-pre.1") is None + assert ranges.parse_stable_tag("dev") is None + # release.py accepts a bare version; a release tag must carry the prefix. + assert ranges.parse_stable_tag("0.1.0") is None + + assert ranges.parse_prerelease_tag("v2.10.3-pre.12") == (2, 10, 3, 12) + assert ranges.parse_prerelease_tag("v2.10.3") is None + assert ranges.parse_prerelease_tag("v2.10.3-pre.0") is None + assert ranges.parse_prerelease_tag("2.10.3-pre.1") is None + + +def test_selects_the_newest_stable_strictly_before_the_candidate_train() -> None: + tags = ["v0.1.9", "v0.1.10", "v0.2.0-pre.1", "v0.2.0", "vm-runtime", "0.1.11"] + assert ranges.select_previous_stable(tags, (0, 2, 0)) == "v0.1.10" + assert ranges.select_previous_stable(["v0.1.0"], (0, 1, 0)) is None + + +def test_resolves_a_cumulative_prerelease_range_from_git_history(repo: Path) -> None: + _commit(repo, "stable") + _git(repo, "tag", "v0.1.0") + _commit(repo, "pre one") + _git(repo, "tag", "v0.1.1-pre.1") + _commit(repo, "pre two") + _git(repo, "tag", "v0.1.1-pre.2") + _publish_main(repo) + + result = ranges.resolve_range(repo=repo, candidate="v0.1.1-pre.2") + + assert result["base_tag"] == "v0.1.0" + assert result["candidate_tag"] == "v0.1.1-pre.2" + assert result["train"] == "v0.1.1" + assert result["category"] == "codex-security/v0.1.1" + assert result["scan_scope"] == "diff" + assert result["commit_count"] == "2" + + +def test_rejects_a_candidate_that_is_not_a_prerelease_tag(repo: Path) -> None: + _commit(repo, "stable") + _git(repo, "tag", "v0.1.0") + _publish_main(repo) + + for candidate in ("v0.1.0", "v0.1.0-pre.0", "0.1.1-pre.1", "dev"): + with pytest.raises(ranges.ReleaseRangeError, match="candidate must match"): + ranges.resolve_range(repo=repo, candidate=candidate) + + +def test_rejects_a_prerelease_that_is_not_on_main(repo: Path) -> None: + _commit(repo, "stable") + _git(repo, "tag", "v0.1.0") + _publish_main(repo) + _git(repo, "switch", "--create", "detached-release") + _commit(repo, "off-main candidate") + _git(repo, "tag", "v0.1.1-pre.1") + + with pytest.raises( + ranges.ReleaseRangeError, match="is not an ancestor of origin/main" + ): + ranges.resolve_range(repo=repo, candidate="v0.1.1-pre.1") + + +def test_requires_explicit_approval_before_a_full_bootstrap_scan(repo: Path) -> None: + _commit(repo, "first candidate") + _git(repo, "tag", "v0.1.0-pre.1") + _publish_main(repo) + + with pytest.raises(ranges.ReleaseRangeError, match="--allow-full-bootstrap"): + ranges.resolve_range(repo=repo, candidate="v0.1.0-pre.1") + + approved = ranges.resolve_range( + repo=repo, candidate="v0.1.0-pre.1", allow_full_bootstrap=True + ) + assert approved["scan_scope"] == "full" + assert approved["base_tag"] == "" + assert approved["train"] == "v0.1.0" + + +def test_rejects_a_stable_override_newer_than_the_train(repo: Path) -> None: + _commit(repo, "stable") + _git(repo, "tag", "v0.2.0") + _commit(repo, "candidate") + _git(repo, "tag", "v0.1.1-pre.1") + _publish_main(repo) + + with pytest.raises(ranges.ReleaseRangeError, match="must be older than"): + ranges.resolve_range(repo=repo, candidate="v0.1.1-pre.1", stable="v0.2.0") + + +def test_cli_writes_github_outputs_and_json(repo: Path, tmp_path: Path) -> None: + _commit(repo, "stable") + _git(repo, "tag", "v0.1.0") + _commit(repo, "candidate") + _git(repo, "tag", "v0.1.1-pre.1") + _publish_main(repo) + outputs = tmp_path / "github-output.txt" + + result = _run(repo, "--candidate", "v0.1.1-pre.1", "--github-output", str(outputs)) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout)["scan_scope"] == "diff" + written = dict( + line.split("=", 1) for line in outputs.read_text(encoding="utf-8").splitlines() + ) + assert written["base_tag"] == "v0.1.0" + assert written["category"] == "codex-security/v0.1.1" + + +def test_cli_fails_without_bootstrap_approval(repo: Path) -> None: + _commit(repo, "first candidate") + _git(repo, "tag", "v0.1.0-pre.1") + _publish_main(repo) + + result = _run(repo, "--candidate", "v0.1.0-pre.1") + + assert result.returncode == 1 + assert "--allow-full-bootstrap" in result.stderr diff --git a/tasks/test.toml b/tasks/test.toml index 2fc3c565e4..4a5cda0890 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -48,7 +48,7 @@ hide = true ["test:codex-security-release-range"] description = "Test Codex Security release-range resolution" -run = "node --test tasks/scripts/codex-security-release-range.test.mjs" +run = "uv run --no-project --with pytest pytest -o \"python_files=*_test.py\" tasks/scripts/codex_security_range_test.py" hide = true [e2e]