diff --git a/.github/workflows/build-baseline.yml b/.github/workflows/build-baseline.yml index 13de8e648..64c2c851d 100644 --- a/.github/workflows/build-baseline.yml +++ b/.github/workflows/build-baseline.yml @@ -97,9 +97,7 @@ jobs: Write-AntivirusEvidence "Antivirus check: no explicit antivirus telemetry was available on this hosted runner." - name: Activate and verify pinned npm runtime - run: | - corepack enable npm - npm run check:npm-runtime + run: bash scripts/checks/activate_pinned_npm_runtime.sh - name: Install node dependencies run: npm ci - name: Sync Python dependencies @@ -196,9 +194,7 @@ jobs: Write-AntivirusEvidence "Antivirus check: no explicit antivirus telemetry was available on this hosted runner." - name: Activate and verify pinned npm runtime - run: | - corepack enable npm - npm run check:npm-runtime + run: bash scripts/checks/activate_pinned_npm_runtime.sh - name: Install node dependencies run: npm ci - name: Sync Python dependencies @@ -269,9 +265,7 @@ jobs: - name: Install create-dmg run: brew install create-dmg - name: Activate and verify pinned npm runtime - run: | - corepack enable npm - npm run check:npm-runtime + run: bash scripts/checks/activate_pinned_npm_runtime.sh - name: Install node dependencies run: npm ci - name: Sync Python dependencies @@ -333,9 +327,7 @@ jobs: - name: Install create-dmg run: brew install create-dmg - name: Activate and verify pinned npm runtime - run: | - corepack enable npm - npm run check:npm-runtime + run: bash scripts/checks/activate_pinned_npm_runtime.sh - name: Install node dependencies run: npm ci - name: Sync Python dependencies diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e743c2ff..483dedc15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,6 @@ env: GIT_CONFIG_COUNT: "1" GIT_CONFIG_KEY_0: init.defaultBranch GIT_CONFIG_VALUE_0: develop - EXPECTED_NPM_VERSION: "10.9.9" jobs: lock-validation: @@ -37,12 +36,8 @@ jobs: with: node-version: "22.22.3" package-manager-cache: false - - name: Activate pinned npm runtime - run: corepack enable npm - - name: Verify exact npm lockfile generator and bundled tar - run: | - test "$(npm --version)" = "$EXPECTED_NPM_VERSION" - npm run check:npm-runtime + - name: Activate and verify pinned npm runtime + run: bash scripts/checks/activate_pinned_npm_runtime.sh - name: Validate the frozen package lock without lifecycle execution run: npm ci --ignore-scripts --no-audit --no-fund - name: Reject manifest or lockfile drift @@ -60,16 +55,12 @@ jobs: with: node-version: "22.22.3" package-manager-cache: false - - name: Activate pinned npm runtime - run: corepack enable npm - - name: Verify exact npm lockfile generator and bundled tar - run: | - test "$(npm --version)" = "$EXPECTED_NPM_VERSION" - npm run check:npm-runtime - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: version: "0.8.6" enable-cache: false + - name: Activate and verify pinned npm runtime + run: bash scripts/checks/activate_pinned_npm_runtime.sh - name: Install node dependencies run: npm ci - name: Sync Python dependencies @@ -99,14 +90,10 @@ jobs: with: node-version: "22.22.3" package-manager-cache: false - - name: Activate pinned npm runtime - run: corepack enable npm - - name: Verify exact npm lockfile generator and bundled tar - run: | - test "$(npm --version)" = "$EXPECTED_NPM_VERSION" - npm run check:npm-runtime - name: Install stable Rust toolchain run: rustup toolchain install stable --profile minimal + - name: Activate and verify pinned npm runtime + run: bash scripts/checks/activate_pinned_npm_runtime.sh - name: Install node dependencies run: npm ci - name: Build frontend @@ -115,3 +102,53 @@ jobs: run: cargo +stable check --manifest-path apps/desktop/src-tauri/Cargo.toml --locked - name: Test Tauri shell run: cargo +stable test --manifest-path apps/desktop/src-tauri/Cargo.toml --locked + + node-minimum-compatibility: + name: gate / ci / node-minimum-compatibility + runs-on: macos-15 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 22.22.2 + package-manager-cache: false + - name: Activate and verify pinned npm runtime + run: bash scripts/checks/activate_pinned_npm_runtime.sh + - name: Install frozen Node dependencies + run: npm ci --ignore-scripts --no-audit --no-fund + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + version: "0.8.6" + enable-cache: false + - name: Sync Python dependencies + run: uv sync --project services/analysis-engine --group dev --frozen + - name: Install stable Rust toolchain + run: rustup toolchain install stable --profile minimal + - name: Build and install Rust numeric extension + shell: bash + run: | + VENV_PY="$PWD/services/analysis-engine/.venv/bin/python" + uvx maturin@1.9.6 build --release \ + --manifest-path services/analysis-engine/rust/Cargo.toml \ + --interpreter "$VENV_PY" \ + --out services/analysis-engine/rust/dist + uv pip install --python "$VENV_PY" services/analysis-engine/rust/dist/*.whl + - name: Lint + run: npm run lint + - name: Typecheck + run: npm run typecheck + - name: Test with measured coverage + run: npm run test + - name: Build production workspaces + run: npm run build + - name: Build Storybook + run: npm run build-storybook --workspace @bandscope/desktop + - name: Check Tauri shell + run: cargo +stable check --manifest-path apps/desktop/src-tauri/Cargo.toml --locked + - name: Test Tauri shell + run: cargo +stable test --manifest-path apps/desktop/src-tauri/Cargo.toml --locked diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aa69a973c..449b3e064 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,9 +45,7 @@ jobs: - name: Install Rust stable run: rustup toolchain install stable --profile minimal - name: Activate and verify pinned npm runtime - run: | - corepack enable npm - npm run check:npm-runtime + run: bash scripts/checks/activate_pinned_npm_runtime.sh - name: Install node dependencies run: npm ci - name: Sync Python dependencies diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index 07754a782..eb2427973 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -42,9 +42,7 @@ jobs: version: "0.8.6" enable-cache: false - name: Activate and verify pinned npm runtime - run: | - corepack enable npm - npm run check:npm-runtime + run: bash scripts/checks/activate_pinned_npm_runtime.sh - name: Install node dependencies run: npm ci - name: Audit npm dependencies diff --git a/CLAUDE.md b/CLAUDE.md index b5a34c1fa..58303dcf2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ Agent execution and delegation rules live in `docs/agents/README.md`. PR canonic ## Common commands -Setup (Node >=22.13 <23, Python >=3.12 via `uv`, Rust stable only for the Tauri shell): +Setup (Node >=22.22.2 <23, Python >=3.12 via `uv`, Rust stable only for the Tauri shell): ```bash npm install @@ -51,7 +51,7 @@ BandScope is a local-first desktop app for rehearsal prep: it turns a song into Three layers, decoupled through shared contracts: -- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The ready workspace names tonight's first playable range and the next instrument check. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. +- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. - `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis. - `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e09719b22..459db0905 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -38,8 +38,8 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", "@vitest/coverage-v8": "^4.1.10", - "eslint": "^10.7.0", - "jsdom": "^29.1.1", + "eslint": "^10.9.1", + "jsdom": "^30.0.1", "storybook": "^10.4.6", "tailwindcss": "^4.2.4", "typescript": "^6.0.3", diff --git a/docs/doctoring/npm-lockfile-generator-provenance.md b/docs/doctoring/npm-lockfile-generator-provenance.md index 72a31befc..41b356b55 100644 --- a/docs/doctoring/npm-lockfile-generator-provenance.md +++ b/docs/doctoring/npm-lockfile-generator-provenance.md @@ -2,34 +2,27 @@ ## Decision -BandScope records npm `10.9.9` as the approved generator for root workspace dependency updates. The root manifest records that decision through: +BandScope records npm `10.9.9` as the approved generator for root workspace dependency updates. The root manifest records that decision through `packageManager: npm@10.9.9` and `devEngines.packageManager` with `onFail: error`. npm is intentionally not repeated under runtime `engines`; the package-manager generator and the supported Node runtime are separate contracts. -- `packageManager: npm@10.9.9` as package-manager selection metadata; and -- `devEngines.packageManager` with `onFail: error` as npm's source-tree command gate. +The #779/jsdom 30 compatibility slice raises the supported Node 22 interval to `>=22.22.2 <23`. Primary CI continues to exercise Node `22.22.3`, while `.github/workflows/node-minimum-compatibility.yml` exercises the exact `22.22.2` lower boundary. Both lanes activate the repository-pinned npm runtime with Corepack, verify npm `10.9.9` and its bundled `tar` security floor before dependency extraction, and consume only the committed lock with frozen `npm ci`. -The npm version is intentionally not repeated under `engines`. npm serializes `engines` into the root lock package, so adding an npm-only source-tool constraint there creates lock metadata churn unrelated to dependency resolution. `devEngines` and the explicit CI assertion enforce the approved generator while the published `engines.node` range remains the runtime compatibility contract. +The canonical compatibility branch regenerated `package-lock.json` as one complete artifact with Node `22.22.2` and the approved npm `10.9.9` generator after integrating the jsdom `30.0.1` and ESLint `10.9.1` manifest intents. It did not hand-edit or partially transplant the predecessor lock. The generated graph carries the Node floor, the jsdom 30 workspace graph, both ESLint ranges, registry integrity evidence, and root `@esbuild/*` peer metadata. Merge readiness still requires frozen consumption and all fresh exact-head repository gates. -Primary CI does **not** regenerate or update `package-lock.json`. It uses Node `22.22.3`, enables the npm shim supplied by the Node-bundled Corepack, resolves the project-pinned npm `10.9.9`, verifies that exact npm runtime and its own bundled `tar` package before dependency consumption, and validates the committed lock with `npm ci --ignore-scripts --no-audit --no-fund`. The gate then rejects any manifest or lockfile working-tree change. The normal verification jobs repeat the same runtime provenance gate before the repository's reviewed `npm ci` installation. +## Why npm 10.9.9 is authoritative -The Node runtime support decision remains separate. This change does not raise the public `>=22.13 <23` Node range; a coordinated Node-floor migration is tracked independently. +The prior approved npm `10.9.8` bundled `tar 7.5.11`. GitHub advisory GHSA-23hp-3jrh-7fpw / CVE-2026-59873 records `tar <=7.5.18` as affected by an unbounded decompression/parse denial-of-service vulnerability and `7.5.19` as the patched floor. npm `10.9.9` bundles `tar 7.5.22`. -## Why the npm runtime was advanced - -The prior approved npm `10.9.8` bundled `tar 7.5.11`. GitHub's reviewed advisory GHSA-23hp-3jrh-7fpw / CVE-2026-59873 marks `tar <=7.5.18` affected by an unbounded decompression/parse denial-of-service vulnerability and records `7.5.19` as the patched floor. npm `10.9.9` updates its bundled `tar` to `7.5.22`. - -The Node 22 distribution line still bundled npm `10.9.8` when this repair was made, so merely advancing the Node 22 patch selector did not remove the vulnerable package-manager runtime. BandScope therefore keeps the supported Node 22 contract and activates the repository-pinned npm `10.9.9` through bundled Corepack before any `npm ci` step. `scripts/checks/verify_npm_runtime.mjs`, executed through that npm runtime, locates the running npm package via `npm_execpath`, verifies npm `10.9.9`, reads npm's own `node_modules/tar/package.json`, and rejects a tar version below `7.5.19` before dependency extraction is allowed. +Node `22.22.2` and `22.22.3` ship an older bundled npm, so merely selecting the Node patch release is not sufficient. BandScope enables the project-pinned npm shim before repository-scoped npm dependency consumption. `scripts/checks/verify_npm_runtime.mjs`, executed by that selected npm runtime, verifies npm `10.9.9` and rejects a bundled `tar` below `7.5.19`. This is a package-manager execution boundary, not an application dependency override. BandScope does not add `tar` to the application graph or suppress the advisory. -## Why generator provenance still matters - -npm documents `package-lock.json` as the location-keyed description of the exact dependency tree. Lockfile version 3 is intended for npm 9 and newer. npm also notes that package-manager versions and tree-shaping configuration can affect the generated dependency graph and metadata. Dependency updates therefore use the reviewed npm `10.9.9` toolchain, and reviewers examine the complete generated lock diff together with its manifest change. +## Why generator provenance matters -That provenance is distinct from CI validation. `npm ci` is the immutable consumption path: it requires a lockfile, rejects manifest/lock dependency disagreement, removes an existing `node_modules`, and never writes the manifest or lock. CI relies on that frozen behavior instead of running `npm install`, `npm update`, or `npx` commands that may perform mutable resolution. +npm documents `package-lock.json` as the location-keyed description of the exact dependency tree. Package-manager versions and tree-shaping configuration can affect generated graph and metadata. Dependency changes therefore use the reviewed npm `10.9.9` toolchain and reviewers examine the complete generated lock diff together with manifest intent. -The repository additionally requires a Subresource Integrity value for every package-lock entry resolved from the public npm registry. npm documents `integrity` as the SHA-512 or SHA-1 SRI string for the artifact unpacked at that location. +CI validation is deliberately different from generation. `npm ci` requires a lockfile, rejects manifest/lock disagreement, removes an existing `node_modules`, and never rewrites the manifest or lock. Primary CI and the exact-minimum Node lane use this immutable path; they do not run `npm install`, `npm update`, `npx`, or another mutable resolution command to make a stale lock appear green. -The root lock also retains `peer: true` on the platform-specific `node_modules/@esbuild/*` records produced by the approved tree. Multiple dependency-update branches generated with a different serialization path were observed removing those markers even when the requested package change was unrelated to esbuild. Because frozen `npm ci` consumes rather than regenerates the lock, ordinary frozen-install validation alone cannot prove that this generator-sensitive metadata was preserved. The repository therefore treats those markers as a regression sentinel: a dependency PR that strips them must be regenerated with the approved npm toolchain rather than normalizing the unrelated churn by hand. +Every package-lock entry resolved from the public npm registry must retain Subresource Integrity evidence. The root lock also retains `peer: true` on platform-specific `node_modules/@esbuild/*` records. Loss of those markers is treated as generator drift and requires regeneration with the approved toolchain rather than manual normalization. ```mermaid flowchart LR @@ -37,63 +30,92 @@ flowchart LR C --> R[verify npm 10.9.9 and bundled tar >= 7.5.19] R --> G[approved npm update toolchain] G --> L[reviewed package-lock.json v3] - L --> V[npm ci frozen validation, lifecycle disabled] + L --> V[npm ci frozen validation] V --> D{manifest or lock drift?} D -->|yes| F[fail closed] D -->|no| S[verify SRI and generator-sensitive metadata] - S --> N[normal npm ci and repository checks] + S --> Q[full product and security gates] ``` +## Exact-minimum Node verification + +jsdom `30.0.1` declares a Node floor compatible with Node `22.22.2`. BandScope intentionally stays on the Node 22 line for this migration, so the repository contract is `>=22.22.2 <23` rather than an implicit expansion to Node 24 or 26. + +The dedicated minimum-runtime workflow must: + +1. check out without persisted credentials; +2. install exact Node `22.22.2` with package-manager cache discovery disabled; +3. run `corepack enable npm` before repository dependency consumption; +4. verify exact npm `10.9.9` and bundled `tar >=7.5.19` through `check:npm-runtime`; +5. run frozen `npm ci --ignore-scripts --no-audit --no-fund`; and +6. run lint, strict typecheck, measured tests, production build, Storybook, and locked Tauri check/test. + +The ordering is security-significant: setup-node must not invoke npm cache discovery through the Node-bundled npm before the reviewed project npm runtime is authoritative. + +## Hosted build acquisition resilience + +Exact npm provenance and network resilience are separate concerns. Node `22.22.3` supplies npm `10.9.8`, so a hosted build must acquire the repository-pinned npm `10.9.9` before `npm ci`; falling back to the Node-bundled npm would violate the reviewed runtime and `tar` security floor. + +A macOS Intel `build-baseline` run on PR #1232 exposed the acquisition boundary: Corepack attempted to download `npm-10.9.9.tgz` from the public npm registry and the HTTPS read timed out before any repository dependency consumption or product build began. Re-running the whole job would hide the architectural weakness and waste all preceding setup work. + +The canonical #896 owner therefore centralizes native-build activation in `scripts/checks/activate_pinned_npm_runtime.sh`. The helper reads the exact `packageManager` spec from `package.json`, rejects anything other than an exact npm semantic version, and performs at most three `corepack install --global` attempts for that same spec with bounded 5-second then 10-second backoff. After acquisition it enables the npm shim and executes the existing runtime audit. Exhausting the attempts fails closed; there is no `10.9.8`, `latest`, `stable`, or system-npm fallback. + +This retry boundary covers only acquisition of the already-reviewed package-manager artifact. It does not retry `npm ci`, mutable resolution, tests, builds, uploads, or arbitrary failed commands, and it does not convert a reproducible integrity/version failure into success. + ## Security and operational boundary - Every primary CI job that consumes npm dependencies activates the project-pinned npm runtime and runs `check:npm-runtime` before its first `npm ci`. -- The runtime check fails closed unless the executing npm is exactly `10.9.9` and its own bundled `tar` is at least `7.5.19`. -- CI lock validation must not run `npm install`, `npm update`, `npx`, or another mutable dependency-resolution command. -- Dependency PRs change manifest intent and the complete lock artifact produced by the approved npm `10.9.9` update toolchain; reviewers reject unexplained lock churn rather than hand-editing records. -- Platform-specific root `@esbuild/*` lock records must retain their expected `peer: true` metadata. Missing markers are treated as generator drift, not as an acceptable side effect of an unrelated dependency update. -- The lock-validation job disables dependency lifecycle scripts. The normal clean install retains the repository's reviewed execution behavior. -- Registry-resolved package records require SRI evidence in the committed lock. -- Install-shaping flags that affect the dependency tree, such as `legacy-peer-deps` or `install-links`, must be committed in project configuration and applied consistently to generation and `npm ci`. -- The root `package-lock.json` remains the sole npm workspace lock. Nested workspace locks are prohibited. - -`packageManager` alone is not the enforcement boundary for npm because Node distributions do not enable Corepack's npm shim by default. Enforcement is provided by explicit `corepack enable npm`, npm `devEngines`, the exact runtime/tar provenance check, the frozen `npm ci` contract, and repository tests that prohibit mutable resolution in the lock gate. +- Native build-baseline jobs acquire that exact runtime through the bounded helper; transient registry reads may retry, but runtime identity and the bundled `tar` floor never relax. +- The runtime check fails closed unless npm is exactly `10.9.9` and its own bundled `tar` is at least `7.5.19`. +- CI lock validation and the exact-minimum lane must not run mutable npm resolution. +- Dependency PRs change manifest intent and the complete lock artifact produced by npm `10.9.9`; unexplained lock churn is rejected rather than hand-edited. +- Registry-resolved lock records require SRI evidence, and root `@esbuild/*` platform records retain expected peer metadata. +- Checkout credentials are not persisted in npm-consuming CI jobs. +- Install-shaping flags that affect the tree must be committed and applied consistently to generation and frozen consumption. +- The root `package-lock.json` remains the sole npm workspace lock; nested workspace locks are prohibited. ## Verification -`services/analysis-engine/tests/test_npm_toolchain_contract.py` verifies: +`services/analysis-engine/tests/test_npm_toolchain_contract.py` verifies the npm generator metadata, Node/npm identity in primary CI, Corepack/runtime-audit ordering, credential-free checkouts, immutable lock validation, lockfile version 3, SRI evidence, and generator-sensitive esbuild peer metadata. It accepts either the explicit activation/audit sequence or the canonical activation helper before dependency consumption, while preserving the same runtime provenance requirement. -1. the manifest's approved npm metadata and Node/runtime separation; -2. the exact Node/npm identity used by primary CI; -3. Corepack activation and npm runtime/tar verification before every primary npm dependency-consumption step; -4. frozen `npm ci` lock validation with lifecycle execution disabled; -5. absence of `npm install`, `npm update`, and `npx` from the lock-validation job; -6. a clean manifest/lock working tree after validation; -7. package-lock version 3; -8. SRI evidence for every public npm-registry artifact in the root lock; and -9. preservation of `peer: true` on every root `node_modules/@esbuild/*` platform record. +`services/analysis-engine/tests/test_npm_runtime_activation_resilience.py` specifically verifies that all four native build-baseline lanes use the canonical helper immediately before `npm ci`, that inline unbounded Corepack activation is absent there, and that the helper has a three-attempt bounded acquisition loop with no npm `10.9.8` or failure-masking fallback. -The exact PDF.js and Undici baseline is covered separately by `test_high_security_dependency_baseline.py` and the desktop PDF loader tests. +`services/analysis-engine/tests/test_node_runtime_contract.py` separately verifies the `>=22.22.2 <23` interval, explicit rejection of Node `22.22.1`, jsdom 30 manifest/lock alignment, the exact-minimum workflow, npm runtime verification before dependency reads, the full compatibility acceptance surface, and removal of the superseded Node floor from canonical runtime/build documentation. -A dependency update is mergeable only after the updated manifest and complete generated lock are reviewed together and the exact current head passes npm runtime provenance, frozen lock validation, normal install, lint, strict typecheck, measured tests, production build, Rust/Tauri checks, security/supply-chain gates, current review, independent approval, and branch protection without bypass. +The PDF.js and Undici baseline remains covered separately by `test_high_security_dependency_baseline.py` and desktop PDF-loader tests. ## Claim boundary -CI proves that the committed manifest and lock can be consumed as a frozen pair by the approved toolchain, that the npm runtime used for dependency extraction is the reviewed version with a non-vulnerable bundled tar floor, that public-registry lock entries carry integrity evidence, and that the known generator-sensitive `@esbuild/*` peer markers remain present. It does **not** claim that resolving mutable manifest ranges again at a later time will reproduce byte-identical lock metadata. When a dependency update is needed, npm `10.9.9` remains the approved generator and its entire resulting lock diff is review evidence. +Passing frozen validation proves only that the committed manifest and lock can be consumed together by the reviewed toolchain and that the package-manager extraction runtime satisfies the pinned security floor. It does not prove that resolving mutable dependency ranges later will reproduce byte-identical lock metadata. + +Likewise, the exact-minimum lane proves only BandScope's selected Node 22 lower boundary. It does not broaden support to other Node major lines because upstream jsdom supports them. + +Bounded Corepack acquisition proves neither registry availability nor arbitrary network recovery. It only prevents a short-lived fetch interruption from forcing an immediate whole-job failure while preserving exact npm identity; three failed acquisition attempts still stop the job. + +For the active compatibility branch, local generation and frozen-consumption evidence does not substitute for required exact-head CI, security, supply-chain, coverage, build, release, and independent-review gates on an unchanged head. ## Incident response and rollback When an update produces unexpected lock churn or npm runtime provenance fails: 1. preserve the exact head SHA, npm, bundled tar and Node versions, project npm configuration, original lock blob SHA, generated lock, and relevant CI run IDs; -2. determine whether manifest intent, npm, project configuration, registry metadata, transitive dependency resolution, or the package-manager runtime changed; -3. never accept a partial or hand-edited lock or disable the runtime check to satisfy a validator; -4. regenerate the complete lock in a dedicated update branch using the reviewed npm version, then review the full diff before relying on it; and -5. if rollback is necessary, restore the prior manifest and complete lock together, then rerun the entire exact-head gate. Do not roll back to a package-manager runtime with a known unfixed extraction vulnerability without an explicit temporary security exception. +2. determine whether manifest intent, npm, project configuration, registry metadata, transitive resolution, or package-manager runtime changed; +3. never accept a partial/hand-edited lock or disable the runtime check; +4. regenerate the complete lock in the canonical dependency branch using npm `10.9.9`, then review the full diff before relying on it; and +5. if rollback is necessary, restore the prior manifest and complete lock together and rerun the entire exact-head gate. + +For an exact-minimum runtime failure, preserve setup-node details, bundled npm identity, first npm invocation, Corepack activation, and exact workflow job log. Do not weaken `devEngines`; repair ordering so the reviewed npm runtime is authoritative before dependency consumption. + +For a transient Corepack registry read failure in native builds, preserve the job log and the exact pinned package-manager spec. The bounded helper may retry only that acquisition. If all attempts fail, keep the gate failed and investigate registry/network health; do not fall back to the Node-bundled npm or manufacture a no-op commit to obtain a fresh run. ## References GitHub. (2026). *node-tar: Decompression/parse DoS via unlimited input* (GHSA-23hp-3jrh-7fpw; CVE-2026-59873) [Security advisory]. https://github.com/advisories/GHSA-23hp-3jrh-7fpw +jsdom contributors. (2026). *jsdom 30.0.1 package manifest* [Source code]. GitHub. https://github.com/jsdom/jsdom/blob/v30.0.1/package.json + +Node.js contributors. (2026). *Node.js v22.22.2 bundled npm package manifest* [Source code]. GitHub. https://github.com/nodejs/node/blob/v22.22.2/deps/npm/package.json + Node.js contributors. (2026). *Corepack* [Software documentation]. GitHub. https://github.com/nodejs/corepack npm, Inc. (2026). *npm 10.9.9* [Software release]. GitHub. https://github.com/npm/cli/releases/tag/v10.9.9 diff --git a/docs/traceability/npm-package-manager-integrity-pin.md b/docs/traceability/npm-package-manager-integrity-pin.md new file mode 100644 index 000000000..76fc8f622 --- /dev/null +++ b/docs/traceability/npm-package-manager-integrity-pin.md @@ -0,0 +1,64 @@ +# npm package-manager artifact integrity pin + +Status: Proposed + +## Problem + +BandScope already pins npm `10.9.9`, rejects fallback to Node-bundled/system/latest npm, verifies the acquired npm version and bundled `tar` floor, and fails closed on unclassified Corepack acquisition errors. The remaining trust gap was narrower: root `package.json` named only `npm@10.9.9`, so the reviewed repository metadata selected an exact version but did not bind that selection to one exact package-manager artifact digest. + +Corepack supports an integrity suffix in the `packageManager` locator. Its current documentation states that the hash is optional but strongly recommended as a security practice, and its test suite exercises `+sha512.` locators. Repository policy should therefore carry the artifact digest that Corepack is expected to verify instead of relying on a version-only locator plus post-acquisition version inspection. + +This is defense in depth, not a claim that the previous path had no integrity protection. Corepack retains its own registry-signature/integrity checks. The repository-level hash adds an immutable reviewed artifact identity to BandScope's package-manager contract. + +## Constraints + +- npm `10.9.9` remains the only accepted package-manager version for this owner. +- The locator must use SHA-512 and exactly 128 lowercase hexadecimal digits. +- `devEngines.packageManager.version` remains `10.9.9`; the integrity suffix belongs to the Corepack `packageManager` locator, not the npm semantic version. +- `verify_npm_runtime.mjs` still verifies the executing npm version and bundled `tar` floor after acquisition; artifact pinning does not replace runtime postconditions. +- Retry remains limited to the exact Corepack acquisition step and positively identified `ETIMEDOUT`. Signature, integrity, policy, metadata, and unknown failures are not retryable. +- No bundled/system/latest npm fallback is introduced. +- No dependency version, lockfile dependency graph, application behavior, MIR behavior, release permission, or branch-protection threshold changes in this repair. + +## Decision + +The root manifest now pins: + +`npm@10.9.9+sha512.d60fba8cb42f688b81e33c2f1cbef2ad7b977166700ec0ad057f1b6d60ea6ef2524abf673e20c35931cd8305d1dbb8887134d6eefdc0e7b8435bd458bf65b862` + +`scripts/checks/activate_pinned_npm_runtime.sh` accepts only `npm@..+sha512.<128 hex>` package-manager locators before invoking `corepack install --global`. A version-only locator now fails before acquisition. + +The SHA-512 digest corresponds to the npm `10.9.9` artifact integrity value `sha512-1g+6jLQvaIuB4zwvHL7yrXuXcWZwDsCtBX8bbWDqbvJSSr9nPiDDWTHNgwXR27iIcTTW7v3A57hDW9RYv2W4Yg==` when represented in hexadecimal. The final authority remains actual Corepack verification on the exact hosted head; the encoded value is not treated as GREEN merely because it is documented here. + +## RED → repair lineage + +- RED `fb73bc99db83e9c1e243cb45a9d095fd229cd20e` adds a focused regression requiring the exact integrity-bound npm locator and a helper boundary that rejects version-only package-manager locators. The predecessor source fails both assertions. +- Repair `29361c98a88472007893cea3949f4444bf0f4f76` changes the root manifest from a version-only npm locator to the reviewed SHA-512 locator. +- Repair `f0a0c42ef98018b65f96486eab37f673e1189731` makes the canonical activation helper require an integrity-bound SHA-512 locator before Corepack acquisition. +- Regression alignment `bbcdd3134ecc6edc7b994a546859006b49cbe5a5`, `8feed8ac6fdef76f3aaf9d8a3059540dd649331b`, and `ae078569857d66c49708c5fcdd822cf916b43106` update the deterministic activation harness and existing npm toolchain contract to exercise the hashed locator rather than a retired version-only test fixture. + +No hosted RED is claimed for the test-only head because the causal repair followed before terminal hosted evidence. Fresh exact-head workflow results after this documentation commit are required. + +## Rejected alternatives + +- Keep `npm@10.9.9` only: rejected because it pins semantic version but leaves the repository manifest without the artifact digest Corepack can validate. +- Replace Corepack verification with a home-grown tarball downloader/hash checker: rejected because it would duplicate package-manager acquisition and signature/integrity ownership. +- Accept arbitrary `+sha*` text: rejected because an unbounded algorithm/length grammar weakens the reviewed contract. This owner currently standardizes on SHA-512. +- Derive the hash dynamically from the registry at CI runtime: rejected because mutable network metadata would become the authority for what the repository intended to trust. +- Retry integrity mismatch: rejected because a deterministic trust failure is not a transient availability event. + +## Evidence and claim boundary + +The source contract proves only that BandScope records and requires one expected SHA-512 locator before Corepack acquisition. Hosted acceptance still requires Corepack to acquire that exact locator successfully, the canonical runtime verifier to report npm `10.9.9` with the required bundled `tar`, frozen `npm ci` to succeed, and all applicable current-head CI/security/SBOM/SAST/CodeQL gates to settle. + +A future intentional npm upgrade must update the semantic version, artifact digest, deterministic regressions, and this traceability record together. A digest-only change without an explicit package-manager review is a supply-chain finding. + +## Security Notes + +The package-manager locator is repository-owned policy data, not user input. The helper validates its grammar before passing it as one quoted argument to Corepack. No shell evaluation is introduced. Corepack remains responsible for acquisition and its own upstream verification; BandScope constrains which artifact identity it is willing to request and keeps post-acquisition runtime verification as a second boundary. + +## References + +Node.js contributors. (2026). *Corepack README: Configuring a package* [Documentation]. GitHub. https://github.com/nodejs/corepack/blob/d4dcb1f89741603e776bba9d457425750fa26987/README.md + +Node.js contributors. (2026). *Corepack tests: SHA-512 packageManager locator handling* [Source code]. GitHub. https://github.com/nodejs/corepack/blob/d4dcb1f89741603e776bba9d457425750fa26987/tests/Use.test.ts diff --git a/docs/traceability/npm-runtime-acquisition-failure-classification.md b/docs/traceability/npm-runtime-acquisition-failure-classification.md new file mode 100644 index 000000000..8ecd665ea --- /dev/null +++ b/docs/traceability/npm-runtime-acquisition-failure-classification.md @@ -0,0 +1,116 @@ +# npm runtime acquisition failure classification + +Status: Proposed + +## Problem + +PR #1232 exposed a real `ETIMEDOUT` while Corepack acquired the repository-pinned `npm@10.9.9`. PR #896 added bounded retry for that exact acquisition step. The first implementation retried every non-zero `corepack install --global` result, including signature and integrity failures. A follow-up blacklist stopped known provenance diagnostics, but still treated every unrecognized Corepack failure as transient. + +That blacklist leaves a fail-open classification boundary: a future or differently worded trust/provenance failure would be retried merely because BandScope did not recognize its text. Retryability must be positively established instead. Unknown acquisition failures are not evidence of a transient transport condition. + +Fresh workflow review then found two ownership defects after the helper existed. First, the exact-minimum Node 22.22.2 compatibility lane still invoked `corepack enable npm` inline and triggered package-manager resolution through `npm --version`. Second, after that lane was moved to the helper, the already-registered `ci.yml` still contained three separate inline Corepack/npm-version paths in `lock-validation`, `verify`, and `rust-check`. Those jobs are part of the normal pull-request CI path, so leaving them inline meant the same repository-pinned npm provenance contract still had multiple acquisition behaviors. + +The exact-minimum lane also lived in a newly added standalone workflow. Fresh exact-head pull-request workflow inventories did not materialize that standalone lane while the existing `ci` workflow did materialize. This repository-specific evidence is not promoted into a universal GitHub Actions rule; it is sufficient to show that the intended exact-minimum evidence was absent from the live PR generation. A compatibility gate that is not present in the observed pull-request workflow inventory is not merge evidence. + +## Constraints + +- `npm@10.9.9` remains the only accepted package-manager runtime for this owner branch. +- Node-bundled npm, `latest`, `stable`, system npm, mutable dependency resolution, tests, builds, uploads, and release actions are not fallback targets. +- Only the exact Corepack acquisition step may receive bounded retry. +- The hosted incident actually observed `ETIMEDOUT`; this is the only transient diagnostic admitted by the current policy. +- The admitted timeout path is limited to three attempts with 5 s / 10 s backoff. +- Any unclassified Corepack failure must stop before `corepack enable npm`, `npm run check:npm-runtime`, or `npm ci` can execute. +- Every repository workflow job that consumes Node dependencies under this owner must use the same activation helper before its first `npm ci`; workflow-local Corepack activation is not a second owner implementation. +- The exact-minimum Node compatibility job must live in an already-materialized PR CI workflow rather than relying on a second workflow whose pull-request run is absent from the observed inventory. +- Error classification remains diagnostic-based because the current Corepack command boundary does not expose a stable machine-readable failure taxonomy to this script. +- Protected-base formatting debt owned by another PR is consumed by stack ancestry; it is not copied into this owner as an unrelated patch. +- Repository pull-request workflows currently filter their base branches to `develop`/`main`; #896 must therefore remain based on `develop` while carrying #1176 as a merge-parent prerequisite, otherwise repository CI disappears from the live PR generation. + +## Decision + +`scripts/checks/activate_pinned_npm_runtime.sh` captures and preserves Corepack's failing diagnostic. It retries only when that diagnostic contains the observed transient error code `ETIMEDOUT`. Every other non-zero acquisition result fails immediately as **not classified as transient**. + +This makes signature/integrity failures fail closed without depending on an exhaustive list of current or future Corepack wording. The helper does not disable Corepack verification, alter `COREPACK_INTEGRITY_KEYS`, select another npm version, or guess that an unknown failure is a network event. + +The helper is the single workflow-level activation path for the four native `build-baseline` npm consumers and the four npm-consuming jobs in the registered `ci` workflow: `lock-validation`, `verify`, `rust-check`, and `node-minimum-compatibility`. The three existing CI jobs no longer keep workflow-local `corepack enable npm` plus separate `npm --version` verification. The exact-minimum Node 22.22.2 lane is now a job in `.github/workflows/ci.yml`; the standalone `.github/workflows/node-minimum-compatibility.yml` owner is removed. + +The retry allowlist is intentionally narrow. Additional error codes such as connection reset, DNS retry, or HTTP/server failures must not be admitted from intuition alone; they require a concrete hosted failure, bounded semantics, and a focused regression before this policy expands. + +#1176 remains the canonical single writer for the protected-base Ruff formatting prerequisite. #896 consumes that exact head through ordinary merge ancestry but keeps its PR base on protected `develop`, because the repository workflow triggers are scoped to pull requests targeting `develop` or `main`. This keeps owner lineage and exact-head CI simultaneously observable. + +## Rejected alternatives + +- Retry every Corepack failure three times: rejected because deterministic trust, policy, package metadata, permission, and configuration failures are not transport recovery candidates. +- Maintain a blacklist of known signature/integrity strings and retry everything else: rejected because future or differently worded non-transient failures become retryable by default. +- Broadly classify all network-looking diagnostics as transient: rejected because the current hosted evidence proves `ETIMEDOUT`, not every possible transport or HTTP failure. +- Disable or weaken Corepack signature verification: rejected because that changes the supply-chain trust boundary rather than repairing availability. +- Fall back to Node-bundled npm 10.9.8: rejected because the repository requires npm 10.9.9 and its bundled patched `tar` floor. +- Retry `npm ci` or later build/test commands: rejected because those operations have different side effects and failure semantics. +- Keep inline Corepack activation in any CI job: rejected because it duplicates the same package-manager acquisition contract and can drift from the canonical timeout/trust classifier. +- Keep the exact-minimum compatibility check as a second standalone workflow after its PR runs are absent from the observed exact-head workflow inventory: rejected because source presence without live PR execution does not satisfy the compatibility evidence requirement. +- Copy the protected-base Ruff fix from #1176 into #896: rejected because #1176 is the canonical single writer for that prerequisite and the dependent branch can inherit it through ordinary non-force ancestry. +- Keep #896 retargeted directly onto #1176's branch: rejected after live observation because `.github/workflows/ci.yml` and the other repository pull-request workflows filter on base branch `develop`/`main`; the retargeted generation did not materialize fresh repository CI for the moved head. +- Treat exact-head Ruff failures as runner or tool failures: rejected when the gate emits deterministic file/line repair evidence after runtime and dependency setup succeeded. +- Weaken E501 or alter the repository's 100-column Ruff policy to accommodate one generated line: rejected because the assertion can remain semantically identical and satisfy both formatter and lint contracts with a shorter local index name. + +## Evidence and regression + +Earlier RED `4b64860cc19a0b60a6f768ef1a88e49ea024992d` proved a fake Corepack signature mismatch must stop after one install attempt with no sleep, npm enable, or npm audit. GREEN `02ae08966b491570ba8f056ac04f1d0e2b285e2c` and alignment `61713a2c8e2053476c400b8a16971e21dd0b7fac` stopped then-known signature/integrity diagnostics. + +Fresh review found the remaining default-retry defect. RED `b8fcb799ca83c4dbfa56fed6802a647dbf785bfa` adds an unclassified Corepack failure and requires one attempt only, no sleep, no enable, no npm audit, preserved upstream diagnostic, and an explicit `not classified as transient` refusal. + +GREEN `9c39c2a595ac5e192c53ed207df2cec6e188b483` reverses the classifier: only `ETIMEDOUT` is admitted to the bounded retry loop; any other failed acquisition exits immediately. Fixture alignment `68f71e02d47a5cd90e4c2dce474f6d1b0f8ef5e3` makes the positive retry regression emit the same `ETIMEDOUT` class observed in hosted CI, preserving the two-timeout-then-success and three-timeout-exhaustion contracts without using an unspecified failure as evidence of transience. + +A later workflow sweep found that the exact-minimum workflow still bypassed the helper. RED `32928847536a301f7966a20db9420f08cd1b5354` required that consumer to use the canonical helper and forbade inline `corepack enable npm` / `npm --version`; GREEN `97042e151f60fe70ab49a7a6e822964bddeed767` rewired the workflow. Exact-tree review then found the structural regression itself still asserted the retired inline path, and `e2420beb411da4fce7c13d7d9c427bf652269008` aligned that regression with the helper contract. + +A fresh live `ci.yml` review then exposed the remaining duplicate owners. RED `4830abb4db7b0741ee202582de721af0317750ce` requires the exact-minimum job to live in registered `ci.yml`, rejects the standalone workflow, and requires `lock-validation`, `verify`, `rust-check`, and `node-minimum-compatibility` to delegate activation to the helper with no inline `corepack enable npm` / `npm --version` path. GREEN `99b0707c61099a170695b66f644fd90162fb7f8c` moves the exact-minimum job into `ci.yml`, converts the three existing CI jobs to the helper, removes the redundant workflow-global npm version variable, and deletes the standalone workflow. + +Exact head `c010a66fedec3647274a27900a11203e07ee671e` then materialized `gate / ci / node-minimum-compatibility` in the live PR CI workflow. On hosted macOS 15 it successfully reached Node 22.22.2, canonical npm activation, verified npm 10.9.9 with bundled tar 7.5.22, frozen dependency installation, Python dependency sync, and the Rust numeric-extension build. Its first source-backed failure was the repository Ruff formatting gate, not package-manager acquisition. + +That Ruff failure named four files. Three were #896-owned regression files; `78bcc37334c512e15289503294bceaff57c5f927` aligns their formatting and removes a stale test dependency on the deleted standalone workflow by reading `node-minimum-compatibility` from registered `ci.yml`. The fourth file, `services/analysis-engine/tests/test_supply_chain_policy.py`, is the canonical formatting delta owned by #1176. Rather than copying it, merge commit `b5dc5bf7834137a8f6b0140b1219e7dbeff7b8db` inherits #1176 exact head `8fe6b6d99c009527ef0bcba419e6f6debdb23c23`. + +#896 was briefly retargeted onto the #1176 branch to make the dependency stack explicit. Fresh Actions inventory then showed the practical consequence of the repository's base-branch filters: after source moved under that base, no exact moved-head repository pull-request workflows materialized. The PR base was therefore restored to protected `develop`; #1176 remains present as a merge parent, so the formatter delta is still inherited from its canonical writer rather than reimplemented locally. + +Exact head `81d7cd910deb7b54250b6397844c15e576df5d9c` added `ruff format --check --diff` so the next hosted failure would carry exact repair evidence rather than only an exit code. Its `gate / ci / node-minimum-compatibility` run again passed Node 22.22.2 setup, canonical npm activation, npm 10.9.9 / bundled tar 7.5.22 verification, frozen Node dependencies, Python sync, stable Rust, and the Rust numeric extension. Ruff then reported exactly three files requiring formatting: `test_node_runtime_contract.py`, `test_npm_runtime_activation_nontransient_failure.py`, and `test_npm_runtime_activation_resilience.py`. No #1176-owned file appeared in this exact-head formatter diff. + +Commit `239bfc76c39811a6a71627a794dae2e1e07488ab` applies only that emitted Ruff formatter result to those three #896-owned regression files. It changes no assertion semantics, dependency/runtime contract, workflow behavior, audio/MIR behavior, or foreign-owner file. + +The next exact head `984d038dc617a0729afaf33e6254c0d8e2323da8` again passed Node 22.22.2 setup, exact npm 10.9.9 activation with bundled tar 7.5.22, frozen Node dependencies, Python sync, stable Rust, and the numeric-extension build. The source-backed failure moved from formatter drift to `ruff check`: E501 rejected one 102-column list-comprehension line in `test_npm_runtime_activation_resilience.py`. The configured Ruff line length is 100. Commit `8507c213b2ad3c5f3d1ab49010adc805a4170429` shortens only the local comprehension index name from `index` to `i`; the list contents, duplicate-detection semantics, workflow contract, and production behavior are unchanged. No lint rule or line-length policy is weakened. + +Because source moved after both hosted failures, their results remain causal predecessor evidence only. The current traceability descendant requires fresh terminal repository and central evidence. + +Predecessor exact head `3983dd216d95dc5f78e78f6b17259ad4c5530ebc` completed all four native Windows/macOS build jobs successfully with exact npm activation. That hosted evidence validates the predecessor command path only; it does not transfer to later moved heads. Current traceability descendants require fresh hosted evidence on the unchanged merge candidate. + +## Risks and claim boundary + +The allowlist may reject a future genuinely transient Corepack error that is not `ETIMEDOUT`. That is an availability tradeoff accepted at the package-manager trust boundary: a false negative causes a visible build failure, while a false positive can repeatedly process an unclassified trust or policy failure as though it were harmless network noise. + +Diagnostic matching still depends on upstream text. If Corepack exposes a stable structured error code or typed result, this script should consume that contract instead. This mechanism does not prove package-manager authenticity by itself; authenticity remains Corepack's verification responsibility, while BandScope controls retry and fallback behavior around that boundary. + +Structural workflow tests prove command ownership and order, not successful hosted acquisition. Moving the exact-minimum lane into `ci.yml` is an evidence-topology repair, not proof that Node 22.22.2 or npm acquisition succeeds on every hosted run. The `81d7...` and `984d...` hosted runs prove the exact npm/runtime path and Rust extension build reached their respective lint gates; neither transfers a GREEN verdict to a moved descendant. + +The #1176 merge parent does not transfer #1176 approvals or central-gate evidence into #896. It only establishes ancestry for the canonical formatting prerequisite. #896 still requires its own exact-head repository/central gates and current-head independent review. Keeping the PR base on `develop` also means the #1176 file remains visible in the protected-base diff until #1176 integrates normally; that visibility is accepted rather than suppressing CI or copying the delta. + +## Follow-up + +- Keep both the hostile signature-failure and unclassified-failure regressions in the exact-head gate. +- Keep every npm-consuming owner workflow job on the canonical activation helper; a new inline Corepack activation path is a repair finding. +- Keep the exact-minimum Node job in the registered CI workflow unless live evidence demonstrates a different canonical execution topology. +- Expand the transient allowlist only from exact observed evidence plus a focused regression and documented retry safety. +- If Corepack introduces a stable structured failure classification, replace diagnostic-string matching with that contract. +- Treat any unclassified failure that reaches sleep/retry as a repair finding, not as permission to broaden fallback behavior. +- Preserve #1176 as the single writer for the protected-base Ruff prerequisite; consume it by ancestry until normal integration reaches `develop`. +- Keep #896 based on protected `develop` while repository workflows remain base-filtered to `develop`/`main`; do not trade away exact-head CI visibility merely to make the stack prettier in the PR UI. +- Require fresh hosted success for the exact-minimum Node 22.22.2 job, normal CI jobs, native build lanes, and applicable central security/SBOM/SAST gates on the unchanged merge candidate head. + +## Security Notes + +The package-manager acquisition diagnostic is untrusted upstream text used only for a bounded classification decision and stderr evidence. It is never evaluated or interpolated into a shell command. The trust boundary is `corepack install --global` returning non-zero: only the exact observed `ETIMEDOUT` token permits another attempt; all other results fail closed before npm activation or dependency extraction. No secret, token, package payload, or mutable version selector is logged by this policy. + +Centralizing workflow activation does not broaden permissions. The helper operates with the same repository checkout and runner process privileges the inline commands already had; the change removes duplicate acquisition paths and places the exact-minimum job inside the existing CI execution surface rather than adding a new credential or network capability. Consuming #1176 as a merge parent adds no new runtime authority; keeping the PR based on `develop` preserves the repository's existing CI trigger surface. + +## References + +Node.js contributors. (2026). *Corepack npm registry signature verification* [Source code]. GitHub. https://github.com/nodejs/corepack/blob/d4dcb1f89741603e776bba9d457425750fa26987/sources/npmRegistryUtils.ts + +Node.js contributors. (2026). *Corepack 0.36.0* [Software release]. GitHub. https://github.com/nodejs/corepack/releases/tag/v0.36.0 diff --git a/docs/traceability/npm-runtime-acquisition-mixed-diagnostic-precedence.md b/docs/traceability/npm-runtime-acquisition-mixed-diagnostic-precedence.md new file mode 100644 index 000000000..ac612de6d --- /dev/null +++ b/docs/traceability/npm-runtime-acquisition-mixed-diagnostic-precedence.md @@ -0,0 +1,72 @@ +# npm runtime acquisition mixed-diagnostic precedence + +Status: Proposed + +## Problem + +BandScope retries the exact `corepack install --global` acquisition step only for the hosted-observed `ETIMEDOUT` transport condition. The existing classifier admitted a retry whenever the captured Corepack diagnostic contained `ETIMEDOUT`. + +That rule was incomplete when one diagnostic contained both a timeout token and a trust/provenance failure. For example, an integrity or signature failure can be emitted together with transport context. Substring admission made the timeout token dominant, so the helper could sleep and retry even though the same diagnostic already established that the failure was not purely transient. + +A retry is not equivalent to bypassing verification, but at this boundary it weakens the stated fail-closed policy: signature, integrity and package-manager metadata failures are deterministic trust failures and must never become retryable merely because the upstream diagnostic also mentions a timeout. + +A later exact-head hosted run exposed a second, platform-specific defect in the same classifier. The helper lowercased diagnostics with Bash 4's `${parameter,,}` expansion. GitHub-hosted macOS executes the helper under the system `/bin/bash`, where that expansion is unsupported, so the classifier aborted with `bad substitution` before it could apply trust-first precedence or bounded timeout retry. Linux did not expose the defect because its hosted Bash supports the expansion. The classification contract therefore also requires a portable lowercase operation across the repository's supported hosted shells. + +## Constraints + +- `npm@10.9.9` plus its repository-pinned SHA-512 identity remains the only admitted package-manager runtime. +- Only `corepack install --global` may receive bounded retry. +- `ETIMEDOUT` remains the only transport token admitted by current hosted evidence. +- Signature, integrity, key-id, package-manager metadata, checksum and hash-mismatch diagnostics take precedence over the timeout token. +- Unknown failures still fail closed. +- The helper must stop before `corepack enable npm` or `npm run check:npm-runtime` on a trust/provenance failure. +- Diagnostic normalization must work on the hosted macOS Bash used by repository CI; shell-version-specific lowercase expansion is not part of the contract. +- This repair does not disable Corepack verification, alter integrity keys, select another npm version, or add an npm/system fallback. + +## RED and repair + +RED `bc26032fd02033458912e26a897b4e7073301e3b` adds a deterministic Corepack fixture whose single diagnostic contains both `Integrity check failed` and `ETIMEDOUT`. The regression requires one acquisition attempt, no sleep, no `corepack enable`, no npm invocation, preserved upstream diagnostic text, and the existing `not classified as transient` refusal. The predecessor helper would admit the timeout branch and retry. + +Repair `4cee8f5d4b0f7496fce959deefdce7912fcb8963` normalizes only for classification and checks trust/provenance markers before the timeout allowlist. Known provenance failure wins when both classes occur in one diagnostic. Pure `ETIMEDOUT` behavior and the existing three-attempt 5 s / 10 s backoff remain unchanged. + +The test-only head was immediately followed by the production repair, so no hosted terminal RED is claimed for `bc26032f...`. + +### Hosted macOS portability RCA + +Exact head `6763d9158cf93798f62d8e42c20ac400b7ca0a1d` produced a real hosted RED in `gate / ci / node-minimum-compatibility` on macOS. Node 22.22.2 setup, integrity-bound npm 10.9.9 acquisition, bundled tar verification, frozen Node dependency installation, Python dependency sync, Rust numeric-extension build, lint and typecheck all completed before the Python regression suite exercised the classifier. The mixed-integrity and unknown-failure tests then observed `/bin/bash: ${acquisition_output,,}: bad substitution`; timeout recovery stopped after one acquisition attempt for the same reason. This is a helper portability defect, not a Corepack/npm acquisition failure. + +Linux on the same exact head independently reached the full Python suite with the helper operating normally and exposed only source-contract regressions: `test_node_runtime_contract.py` still expected the pre-integrity `npm@10.9.9` locator, while `test_npm_runtime_activation_resilience.py` still searched the helper source for uppercase `"ETIMEDOUT"` even though classification intentionally lowercases diagnostics before matching. + +Repair `2a080035cb66ea1a5a4e1cf9b3682c1a23e35b29` replaces Bash-specific `${acquisition_output,,}` with `LC_ALL=C tr '[:upper:]' '[:lower:]'`. This keeps the diagnostic as data, preserves trust-first classification, and works under the hosted macOS Bash path exercised by CI. + +Alignment `e3ecc44a873a44588855312ddcf4f96436d0f86b` removes the stale bare-version package-manager assertion from the Node/jsdom compatibility test. Exact package-manager artifact identity remains owned by `test_npm_package_manager_integrity_pin.py`, which asserts the full version-plus-SHA-512 locator; keeping a second bare-version assertion would encode a contradictory contract. + +Regression update `4974765fd8145eb605637e8a84cf955441b8b3d7` changes the structural classifier assertion to the normalized lowercase `"etimedout"`, requires the portable `LC_ALL=C tr` path, and explicitly rejects reintroduction of `${acquisition_output,,}`. Behavioral fixtures continue to emit uppercase `ETIMEDOUT`, so case-insensitive runtime behavior remains exercised rather than being proven only by source text. + +Because these commits move source after the hosted failure, `6763d915...` remains predecessor RED evidence only. A descendant is not GREEN until its own unchanged exact head completes the applicable repository and central gates. + +## Rejected alternatives + +- Let any diagnostic containing `ETIMEDOUT` retry: rejected because a mixed diagnostic can already prove a non-transient trust failure. +- Disable or weaken Corepack signature/integrity verification: rejected because the availability problem is in BandScope's retry classification, not the trust check. +- Retry unknown failures and maintain only a small fatal blacklist: rejected because unknown is not evidence of transport transience. +- Broaden the transient allowlist to DNS, HTTP, connection-reset or other network-looking failures: rejected because current hosted evidence only supports `ETIMEDOUT`. +- Parse or evaluate diagnostic text as shell: rejected. Upstream text is untrusted evidence and remains data only. +- Require a newer Bash on macOS merely to support `${parameter,,}`: rejected because the helper needs only ASCII case-folding for diagnostic tokens and can perform it portably without broadening runtime prerequisites. +- Duplicate the integrity-bound `packageManager` locator in the Node/jsdom compatibility test: rejected because package-manager artifact identity already has a dedicated canonical regression and duplicate assertions had drifted into contradiction. + +## Claim boundary and residual risk + +This is still a diagnostic-string classifier because the current Corepack command boundary does not provide a stable machine-readable failure taxonomy to this script. The precedence list therefore cannot prove semantic completeness for every future Corepack wording. A new provenance diagnostic that is not represented by the known markers may still require a focused regression and classifier update. + +That residual risk is narrower than the repaired defect: a known trust/provenance marker can no longer be overridden by the admitted timeout token, and the normalization path no longer depends on a Bash feature absent from the hosted macOS shell. If Corepack exposes a stable structured error code or typed result, BandScope should replace text classification rather than expand string heuristics indefinitely. + +## Security Notes + +The captured Corepack diagnostic is untrusted upstream text. It is written to stderr and inspected only as data for a bounded decision. It is never executed, interpolated into a command, or used to choose an alternate package-manager artifact. Trust/provenance evidence has precedence over retry availability. `tr` receives the diagnostic only through stdin and a fixed translation table; it does not evaluate the diagnostic as shell syntax. + +## References + +Node.js contributors. (2026, August 28). *Corepack 0.36.0* [Software release]. GitHub. https://github.com/nodejs/corepack/releases/tag/v0.36.0 + +Node.js contributors. (2026). *verifySignature fails when registry returns dist.signatures on package root but not on version endpoint* (Issue #808). GitHub. https://github.com/nodejs/corepack/issues/808 diff --git a/docs/traceability/npm-workflow-activation-single-path.md b/docs/traceability/npm-workflow-activation-single-path.md new file mode 100644 index 000000000..153be6c5a --- /dev/null +++ b/docs/traceability/npm-workflow-activation-single-path.md @@ -0,0 +1,111 @@ +# npm workflow activation single-path contract + +Status: **Proposed** +Owner: BandScope Node/npm runtime vertical (#896) +Last reviewed: 2026-09-21 + +## Problem + +BandScope's canonical npm owner already treated `scripts/checks/activate_pinned_npm_runtime.sh` as the single workflow-level runtime-admission path. That helper owns exact `packageManager` integrity admission, the npm 10.9.9 runtime check, and the bounded `ETIMEDOUT`-only Corepack acquisition policy. + +The repository policy did not fully enforce that ownership. `release.yml` and `security-audit.yml` still duplicated a weaker inline sequence: + +```sh +corepack enable npm +npm run check:npm-runtime +``` + +The existing structural test explicitly accepted that fallback whenever it appeared before `npm ci`. This meant a workflow could bypass the canonical helper's locator validation and failure-classification behavior while still satisfying the repository test. + +A second policy gap remained after direct-npm discovery was introduced. The detector recognized line starts, shell separators, environment assignments and `command npm`, but it did not recognize ordinary shell control-flow forms such as `then npm`, `do npm`, subshell grouping, negation, or `exec npm`. A workflow could therefore execute npm through normal shell syntax while being misclassified as a non-consumer and escape the single-path invariant. + +A third lexical gap remained after those control-flow forms were covered. POSIX shell removes an unquoted backslash-newline pair before tokenization. A workflow can therefore spell the executable token as `np\` followed by a newline and `m ci`; the runner executes `npm ci`, while a detector operating on the raw YAML string sees no contiguous `npm` token. This is a normal shell continuation rule rather than a separate interpreter or alias, so the structural policy must normalize it before classifying direct npm execution. + +## Constraints + +- Node/npm runtime acquisition remains #896 ownership; downstream product owners must not copy a mutable Draft helper. +- Required checks and workflow permissions must not be weakened to make runtime admission pass. +- The repair must preserve existing release/security job behavior except for routing npm activation through the canonical helper. +- Reusable-workflow call jobs with no local `steps` are not direct shell consumers; repository-owned called workflow files are inspected independently. +- Source-level tests are not promoted to hosted GREEN until the unchanged exact head completes its normal repository and central gates. +- The detector should recognize ordinary shell execution structure without treating arbitrary prose such as `echo npm ci` as executable npm authority. +- Shell continuation handling must model the runner rule narrowly: remove only `\\\r?\n`; do not broadly rewrite whitespace or quoted text. + +## Alternatives considered + +### Keep the inline fallback + +Rejected. It makes the documented single-owner path advisory rather than enforceable and allows helper-specific integrity/failure-classification rules to drift between workflows. + +### Enroll only `release.yml` and `security-audit.yml` + +Rejected. File-name allowlists already proved brittle when Score Storage added a new npm-consuming workflow. The invariant belongs to executable npm consumption, not a fixed workflow inventory. + +### Match only line-start and command-separator forms + +Rejected. Shell control keywords and grouping are ordinary executable syntax. Treating `if ...; then npm ci; fi`, `for ...; do npm ...; done`, `(npm ci)`, `! npm ci`, or `exec npm ci` as non-consumers creates a lexical bypass in the policy gate even though the runner executes npm normally. + +### Ignore escaped-newline joining + +Rejected. Backslash-newline removal happens before shell tokenization. Keeping the raw YAML spelling as policy authority would let a semantically identical `npm` executable evade detection solely because its token crosses a physical source line. + +### Treat every textual `npm` occurrence as execution + +Rejected. A fully lexical substring rule would also classify comments or harmless output such as `echo npm ci` as runtime authority. The current detector remains conservative but execution-oriented: it recognizes supported shell command boundaries and wrappers and requires explicit policy extension if a new execution form is introduced. + +### Patch Score Storage #1241 directly + +Rejected. #1241 is a consumer. It must use the protected/released npm runtime owner after #896 integrates instead of vendoring Draft runtime-acquisition source. + +## RED → repair + +1. `628aa96b0b9d0f87386dbf88fdaed41d2e121c17` adds a repository regression requiring npm-consuming workflow jobs to use the canonical activation helper and rejecting workflow-local Corepack/runtime verification. Repair followed immediately, so no hosted terminal RED is claimed for this test-only head. +2. `2ddd8b151c2223984eaa088f01865427ad04ae0b` routes release preflight through `bash scripts/checks/activate_pinned_npm_runtime.sh`. +3. `ab04754df0f48798bb50d50baf4ab6aa38e89f1e` routes the security backstop through the same helper. +4. `df20e63895968bea105510478ccc04995981d18d` strengthens the regression from literal `npm ci` matching to direct `npm` execution at normal shell-command boundaries, including common environment-assignment and `command npm` forms. This prevents npm command aliases or a different direct npm subcommand from silently escaping runtime admission. +5. `a990e7c70b40dae748123d1447c7ae724edc60e6` removes the older inline-activation fallback from `test_npm_toolchain_contract.py`; both structural regressions now describe the same single canonical activation-path invariant instead of carrying contradictory executable policy. +6. RED `19ddd51d1cb13ed3d783d9e5b81f1ec4f276a787` adds focused detector regressions for `then npm`, `do npm`, subshell grouping, shell negation and `exec npm`. The prior detector fails those cases, so an ordinary shell-wrapped npm consumer could be omitted from policy admission. Repair followed immediately; no hosted terminal RED is claimed for the test-only head. +7. `dc726efbb09d343bacbae0ec273a21390433e01d` extends the execution-boundary detector to shell control keywords, grouping, negation and `exec` while preserving the existing environment-assignment and `command npm` forms. `echo npm ci` remains outside the admitted execution forms rather than becoming a false consumer. +8. RED `237ff476bd9f589c41c3c5a7fe96aa47149d8f19` adds a direct-npm regression whose executable token is split by the shell continuation `np\\\nm ci`. The predecessor detector sees no contiguous `npm` token even though the shell executes `npm ci`. Repair followed immediately, so no hosted terminal RED is claimed for the test-only head. +9. `eee83e3a10cb96e800718e4a8ee4016b7a38e429` normalizes only unquoted escaped newlines (`\\\r?\n`) before applying the existing execution-boundary matcher. This preserves the prior false-positive boundary while making policy classification agree with shell token joining. + +## Exact-head verification finding + +The first hosted generation on `8113cbfcffc60af3cdc9a25df0709548d7fc2bd4` proved the pinned npm helper itself on Node 22.22.2: Corepack installed the integrity-bound npm 10.9.9 locator, `verify_npm_runtime.mjs` reported npm 10.9.9 with bundled tar 7.5.22, and frozen `npm ci` completed. The job then failed at `ruff format --check --diff` because three #896-owned Python regression files were not in canonical Ruff format. + +This was a repository-source defect, not a runner or npm-acquisition failure. It was repaired immediately from the emitted formatter diff: + +- `190a052c60e01fec347c3009df3e5a1869823a7d` formats `test_npm_activation_single_path.py`. +- `a41a2e5b8d3f6e53c7df232dd449b842c866e3c9` formats `test_npm_package_manager_integrity_pin.py`. +- `6366eb66635bada29fe72ec99e55efdeeeaaecd0` formats `test_npm_toolchain_contract.py`. + +The failed `8113cbfc...` verdict is predecessor evidence only. Every later source move, including the shell-control-flow and escaped-newline detector repairs, requires a fresh unchanged-head verdict. + +## Authority and evidence + +npm documents `npm ci` as a clean-install command for automated environments and exposes aliases such as `clean-install`, `ic`, and `install-clean`. Therefore the repository contract guards the npm executable and its execution boundary rather than one spelling of the install subcommand. + +Reference: npm, Inc. (2026). *npm-ci*. https://docs.npmjs.com/cli/commands/npm-ci/ + +The canonical activation helper remains the only place that may acquire/enable the pinned npm runtime. Workflow jobs may execute npm only after that helper returns successfully. + +## Security notes + +The trust boundary is CI dependency-tool execution. A workflow must not reach an npm command under an unreviewed bundled/system/latest runtime or a workflow-local activation sequence that omits the owner helper's integrity and failure-classification policy. + +The structural regression recognizes direct npm execution at line starts, command separators, shell control-flow boundaries, grouping, negation, environment assignments, `command`, and `exec`, after applying the shell's escaped-newline joining rule. Deliberately hiding npm behind another interpreter, generated shell program, quoted/constructed executable token beyond the modeled shell forms, or an unrecognized command wrapper is not an accepted bypass; such a workflow requires explicit policy review and a regression extension before merge. + +## Effect + +- Release and security workflows now consume the same runtime-admission implementation as CI/build owners. +- SHA-512 locator admission, bounded transient acquisition retry, fail-closed nontransient behavior, and runtime verification have one workflow-level owner. +- A future direct npm consumer cannot satisfy the repository test merely by reproducing `corepack enable npm` and `npm run check:npm-runtime` inline. +- Ordinary shell control flow and escaped-newline token joining no longer let an npm consumer disappear from workflow policy admission. +- The original npm-consumer discovery test no longer encodes the rejected inline fallback, preventing future maintenance from reintroducing two conflicting policy definitions. + +## Follow-up + +- Obtain terminal exact-head CI, build, security/SAST/SBOM/CodeQL and independent non-author review for the final #896 head. +- If a workflow needs npm through another interpreter, wrapper, quoted executable construction, or other shell expansion, add an executable regression for that exact form before admitting it; do not silently broaden the bypass surface. +- After #896 reaches protected truth, ordinary/non-force reconcile #1241 and replace its raw bundled-npm dependency admission with the protected canonical helper. +- Re-run #1241's focused ScoreView UI regression on that exact consumer head; do not transfer predecessor failures or successes. diff --git a/docs/traceability/npm-workflow-consumer-discovery.md b/docs/traceability/npm-workflow-consumer-discovery.md new file mode 100644 index 000000000..f96179686 --- /dev/null +++ b/docs/traceability/npm-workflow-consumer-discovery.md @@ -0,0 +1,71 @@ +# npm workflow consumer discovery + +Status: Proposed + +## Problem + +BandScope has one repository-owned npm runtime acquisition boundary: `scripts/checks/activate_pinned_npm_runtime.sh`. The structural regression in `test_npm_toolchain_contract.py` previously enforced that boundary only for four named workflow files: `ci.yml`, `release.yml`, `security-audit.yml`, and `build-baseline.yml`. + +That allowlist was weaker than the owner invariant. A new workflow could run `npm ci` without the canonical activation path and remain invisible to the regression until someone manually added its filename. + +This is no longer theoretical. Score Storage PR #1241 introduced `.github/workflows/score-storage-native.yml`. Its UI job sets up Node 22.22.3 and immediately executes raw `npm ci`; exact-head run `35543493216`, job `106165239933`, failed at `Install locked JavaScript dependencies` before the focused ScoreView regressions ran. The Score Storage branch must not copy the mutable #896 helper, but its failure is valid downstream evidence that filename allowlisting is not a durable repository policy. + +## Constraints + +- #896 remains the single writer for repository-wide Node/npm runtime acquisition and provenance. +- #1241 remains the Score Storage / Score Attachment owner. Its workflow source is not copied into this branch. +- New workflow files must not require manual enrollment before the npm runtime invariant applies. +- Workflows without local npm dependency reads must not be forced to add Node or Corepack setup. +- Reusable-workflow call jobs have no local `steps`; their called workflow is evaluated independently when it is repository-owned under `.github/workflows`. +- `actions/setup-node` dependency caching remains disabled before the reviewed npm runtime is selected and verified. GitHub documents setup-node as a package-manager-specific dependency-cache surface; the repository therefore keeps `package-manager-cache: false` at this pre-admission boundary. +- A structural policy regression is not hosted runtime proof. The final merge candidate still requires exact-head Actions evidence. + +## Decision + +Commit `ff14b04ad57815c6d6c7ab7bb49077f8cd9a05db` replaces the workflow filename allowlist with discovery of every top-level `.yml` and `.yaml` file under `.github/workflows`. Every job with local shell steps is inspected. When a job executes `npm ci`, the existing owner contract is applied: + +1. exactly one checkout step exists and does not persist credentials; +2. exactly one `actions/setup-node` step exists; +3. setup-node package-manager caching is disabled before runtime admission; +4. the canonical npm activation helper executes before the first `npm ci` dependency read. + +Commit `ce4d5264542a036906c0485c6082174cd323a918` preserves that discovery while explicitly skipping job-level reusable-workflow calls that have no local `steps`; repository-owned called workflows remain discoverable as workflow files themselves. + +The downstream #1241 failure is the realistic RED evidence for the missing discovery policy. These commits repair the canonical owner guardrail; they do not claim that #1241 is fixed before #896 reaches protected truth and #1241 is ordinarily reconciled on top of it. + +## Rejected alternatives + +- Add `score-storage-native.yml` to the existing filename tuple: rejected because the next workflow can bypass the policy again. +- Copy `activate_pinned_npm_runtime.sh` into #1241 before #896 integrates: rejected because that creates a mutable second owner and can drift from retry/integrity policy. +- Require Node setup in every workflow regardless of whether it reads npm dependencies: rejected because unrelated SBOM/security/native-only jobs do not cross this trust boundary. +- Treat the #1241 UI failure as evidence that ScoreView regressions failed: rejected because the regression step was skipped after dependency admission failed. +- Blindly rerun #1241: rejected because the source configuration would be unchanged. + +## Evidence and claim boundary + +At #1241 exact head `b29b7b522478780db44db1c754ab7f660ed2b17b`, `score-storage-native` run `35543493216` produced: + +- macOS native job `106165239883`: success; +- Windows native job `106165239907`: success; +- UI job `106165239933`: failure at `Install locked JavaScript dependencies`; the focused UI regression step was skipped. + +The exact #1241 workflow source contains Node 22.22.3 setup with `package-manager-cache: false` followed directly by `npm ci`, with no canonical activation step. This document does not invent the unavailable npm stderr and does not assert that a particular diagnostic string caused the failure. + +The discovery regression proves repository source topology: once an npm-consuming workflow exists in the same candidate tree, it cannot silently bypass the canonical activation ordering without failing the policy test. It does not prove network availability, Corepack registry behavior, npm extraction, application tests, or release readiness. + +## Follow-up + +- Keep #896 Draft until exact-current-head repository/security/native gates and independent review settle. +- After #896 reaches protected `develop`, ordinary/non-force reconcile #1241 and replace its raw npm admission with the protected canonical activation path. +- Run #1241's focused UI job again on that unchanged reconciled head and require the ScoreView/scoreStorage regressions themselves to execute and pass. +- Treat any future npm-consuming workflow that fails the discovery policy as an owner-path repair finding rather than adding another filename exception. + +## Security Notes + +The repair does not add credentials, network permissions, dependency caches, package registries, or fallback runtimes. It broadens only the static enforcement surface from a hand-maintained filename list to the repository's actual workflow inventory. This reduces the chance that a new workflow consumes dependencies using the Node-bundled/system npm before the integrity-bound repository runtime is selected. + +## References + +GitHub. (2026). *Building and testing Node.js*. GitHub Docs. https://docs.github.com/en/actions/tutorials/build-and-test-code/nodejs + +GitHub. (2026). *Dependency caching reference*. GitHub Docs. https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching diff --git a/package-lock.json b/package-lock.json index 1b2ceef69..340f7f567 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "undici": "7.29.0" }, "engines": { - "node": ">=22.13 <23" + "node": ">=22.22.2 <23" } }, "apps/desktop": { @@ -51,8 +51,8 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", "@vitest/coverage-v8": "^4.1.10", - "eslint": "^10.7.0", - "jsdom": "^29.1.1", + "eslint": "^10.9.1", + "jsdom": "^30.0.1", "storybook": "^10.4.6", "tailwindcss": "^4.2.4", "typescript": "^6.0.3", @@ -61,6 +61,105 @@ "vitest": "^4.1.10" } }, + "apps/desktop/node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "apps/desktop/node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "apps/desktop/node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "apps/desktop/node_modules/undici": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "apps/desktop/node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/@adobe/css-tools": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", @@ -74,6 +173,8 @@ "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", @@ -91,6 +192,8 @@ "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", @@ -108,6 +211,8 @@ "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } @@ -117,7 +222,9 @@ "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/@babel/code-frame": { "version": "7.29.7", @@ -4698,9 +4805,9 @@ } }, "node_modules/eslint": { - "version": "10.8.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz", - "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.1.tgz", + "integrity": "sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==", "dev": true, "license": "MIT", "workspaces": [ @@ -5364,6 +5471,8 @@ "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", @@ -7729,7 +7838,7 @@ "devDependencies": { "@types/node": "^26.1.1", "@vitest/coverage-v8": "^4.1.10", - "eslint": "^10.7.0", + "eslint": "^10.9.1", "fast-check": "^4.8.0", "typescript": "^6.0.3", "typescript-eslint": "^8.63.0", diff --git a/package.json b/package.json index 8c118c48f..617cf0a88 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,9 @@ "private": true, "version": "0.1.3", "type": "module", - "packageManager": "npm@10.9.9", + "packageManager": "npm@10.9.9+sha512.d60fba8cb42f688b81e33c2f1cbef2ad7b977166700ec0ad057f1b6d60ea6ef2524abf673e20c35931cd8305d1dbb8887134d6eefdc0e7b8435bd458bf65b862", "engines": { - "node": ">=22.13 <23" + "node": ">=22.22.2 <23" }, "devEngines": { "packageManager": { @@ -29,7 +29,7 @@ "check:npm-runtime": "node scripts/checks/verify_npm_runtime.mjs", "check:python-docstrings": "python3 scripts/checks/run_analysis_command.py ruff check src tests ../../scripts --select D100,D101,D102,D103,D104,D105,D106,D107", "ruff:check": "python3 scripts/checks/run_analysis_command.py ruff check src tests", - "ruff:format:check": "python3 scripts/checks/run_analysis_command.py ruff format --check src tests", + "ruff:format:check": "python3 scripts/checks/run_analysis_command.py ruff format --check --diff src tests", "bandit:check": "python3 scripts/checks/run_analysis_command.py bandit -c pyproject.toml -r src", "lint": "npm run lint:workspaces && npm run check:docs && npm run check:security-notes && npm run check:security-gates && npm run check:supply-chain && npm run check:github-bootstrap && npm run check:python-docstrings && npm run ruff:check && npm run ruff:format:check && npm run bandit:check", "typecheck": "npm run typecheck --workspaces --if-present && python3 scripts/checks/run_analysis_command.py mypy src", diff --git a/packages/shared-types/package.json b/packages/shared-types/package.json index f03474284..44b604e60 100644 --- a/packages/shared-types/package.json +++ b/packages/shared-types/package.json @@ -11,7 +11,7 @@ "devDependencies": { "@types/node": "^26.1.1", "@vitest/coverage-v8": "^4.1.10", - "eslint": "^10.7.0", + "eslint": "^10.9.1", "fast-check": "^4.8.0", "typescript": "^6.0.3", "typescript-eslint": "^8.63.0", diff --git a/scripts/checks/activate_pinned_npm_runtime.sh b/scripts/checks/activate_pinned_npm_runtime.sh new file mode 100644 index 000000000..085da5e0e --- /dev/null +++ b/scripts/checks/activate_pinned_npm_runtime.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly MAX_ATTEMPTS="3" + +package_manager_spec="$({ + node --input-type=module <<'NODE' +import { readFileSync } from "node:fs"; + +const manifest = JSON.parse(readFileSync("package.json", "utf8")); +if ( + typeof manifest.packageManager !== "string" || + !/^npm@[0-9]+\.[0-9]+\.[0-9]+\+sha512\.[0-9a-f]{128}$/.test(manifest.packageManager) +) { + throw new Error( + "package.json must pin packageManager to an exact npm version plus SHA-512 integrity" + ); +} +process.stdout.write(manifest.packageManager); +NODE +} 2>&1)" || { + printf '%s\n' "$package_manager_spec" >&2 + exit 1 +} + +attempt=1 +while true; do + acquisition_output="" + if acquisition_output="$(corepack install --global "$package_manager_spec" 2>&1)"; then + if [[ -n "$acquisition_output" ]]; then + printf '%s\n' "$acquisition_output" >&2 + fi + break + fi + + printf '%s\n' "$acquisition_output" >&2 + normalized_output="$(printf '%s' "$acquisition_output" | LC_ALL=C tr '[:upper:]' '[:lower:]')" + case "$normalized_output" in + *"signature"*|*"integrity"*|*"keyid"*|*"metadata"*|*"checksum"*|*"hash mismatch"*) + echo "Corepack acquisition failure is not classified as transient; refusing to retry or weaken verification." >&2 + exit 1 + ;; + *"etimedout"*) + ;; + *) + echo "Corepack acquisition failure is not classified as transient; refusing to retry or weaken verification." >&2 + exit 1 + ;; + esac + + if (( attempt >= MAX_ATTEMPTS )); then + echo "Failed to acquire $package_manager_spec after $MAX_ATTEMPTS attempts; refusing an unpinned npm fallback." >&2 + exit 1 + fi + + sleep_seconds=$((attempt * 5)) + echo "Corepack acquisition attempt $attempt failed with an admitted transient timeout; retrying exact $package_manager_spec in ${sleep_seconds}s." >&2 + sleep "$sleep_seconds" + attempt=$((attempt + 1)) +done + +corepack enable npm +npm run check:npm-runtime diff --git a/services/analysis-engine/tests/test_node_runtime_contract.py b/services/analysis-engine/tests/test_node_runtime_contract.py new file mode 100644 index 000000000..7430ca59b --- /dev/null +++ b/services/analysis-engine/tests/test_node_runtime_contract.py @@ -0,0 +1,164 @@ +"""Regression tests for the supported Node.js and jsdom compatibility floor.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +EXPECTED_NODE_ENGINE = ">=22.22.2 <23" +EXPECTED_NODE_FLOOR = (22, 22, 2) +EXPECTED_JSDOM_RANGE = "^30.0.1" +EXPECTED_ESLINT_RANGE = "^10.9.1" +CANONICAL_NPM_ACTIVATION = "bash scripts/checks/activate_pinned_npm_runtime.sh" + + +def _load_json(path: str) -> dict[str, object]: + """Load one repository JSON file for an exact contract assertion.""" + return json.loads((ROOT / path).read_text(encoding="utf-8")) + + +def _supports_band_node(version: tuple[int, int, int]) -> bool: + """Model the deliberately narrow supported Node 22 patch interval.""" + return EXPECTED_NODE_FLOOR <= version < (23, 0, 0) + + +def _workflow_job(workflow: str, job_name: str) -> str: + """Return one top-level workflow job body for structural contract checks.""" + match = re.search( + rf"(?ms)^ {re.escape(job_name)}:\n(?P.*?)(?=^ [a-zA-Z0-9_-]+:\n|\Z)", + workflow, + ) + assert match is not None, f"workflow must define {job_name}" + return match.group("body") + + +def test_node_engine_floor_matches_jsdom_30_runtime_contract() -> None: + """Root manifest and lock metadata must publish the same Node compatibility floor.""" + package = _load_json("package.json") + package_lock = _load_json("package-lock.json") + + assert package["engines"] == {"node": EXPECTED_NODE_ENGINE} + assert package_lock["packages"][""]["engines"] == {"node": EXPECTED_NODE_ENGINE} + + +def test_node_floor_rejects_pre_floor_patch_and_accepts_exact_minimum() -> None: + """Node 22.22.1 is unsupported while the exact 22.22.2 floor is supported.""" + assert not _supports_band_node((22, 22, 1)) + assert _supports_band_node((22, 22, 2)) + assert _supports_band_node((22, 99, 0)) + assert not _supports_band_node((23, 0, 0)) + + +def test_jsdom_30_is_adopted_in_manifest_and_lock() -> None: + """The coordinated compatibility slice must carry jsdom 30 in both package graphs.""" + desktop = _load_json("apps/desktop/package.json") + package_lock = _load_json("package-lock.json") + + assert desktop["devDependencies"]["jsdom"] == EXPECTED_JSDOM_RANGE + assert ( + package_lock["packages"]["apps/desktop"]["devDependencies"]["jsdom"] == EXPECTED_JSDOM_RANGE + ) + assert package_lock["packages"]["apps/desktop/node_modules/jsdom"]["version"] == "30.0.1" + + +def test_eslint_10_9_1_intent_is_preserved_in_both_workspaces_and_lock() -> None: + """The canonical lock owner must preserve the reviewed ESLint dependency intent.""" + desktop = _load_json("apps/desktop/package.json") + shared_types = _load_json("packages/shared-types/package.json") + package_lock = _load_json("package-lock.json") + + assert desktop["devDependencies"]["eslint"] == EXPECTED_ESLINT_RANGE + assert shared_types["devDependencies"]["eslint"] == EXPECTED_ESLINT_RANGE + assert ( + package_lock["packages"]["apps/desktop"]["devDependencies"]["eslint"] + == EXPECTED_ESLINT_RANGE + ) + assert ( + package_lock["packages"]["packages/shared-types"]["devDependencies"]["eslint"] + == EXPECTED_ESLINT_RANGE + ) + + +def test_minimum_node_lane_runs_in_registered_ci_with_pinned_npm() -> None: + """Exercise the exact Node floor inside the already-registered CI workflow.""" + workflow = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") + standalone = ROOT / ".github/workflows/node-minimum-compatibility.yml" + + assert not standalone.exists(), ( + "the exact-minimum lane belongs in registered ci.yml, not a second workflow owner" + ) + + body = _workflow_job(workflow, "node-minimum-compatibility") + required_fragments = ( + "node-version: 22.22.2", + "package-manager-cache: false", + CANONICAL_NPM_ACTIVATION, + "npm ci --ignore-scripts --no-audit --no-fund", + "npm run lint", + "npm run typecheck", + "npm run test", + "npm run build", + "npm run build-storybook --workspace @bandscope/desktop", + "cargo +stable check --manifest-path apps/desktop/src-tauri/Cargo.toml --locked", + "cargo +stable test --manifest-path apps/desktop/src-tauri/Cargo.toml --locked", + ) + for fragment in required_fragments: + assert fragment in body, f"minimum-version job is missing: {fragment}" + + for mutable_command in ("npm install ", "npm update ", "npx "): + assert mutable_command not in body, ( + "minimum-version workflow must not resolve dependencies mutably: " + f"{mutable_command.strip()}" + ) + + +def test_all_registered_ci_npm_consumers_delegate_activation_to_helper() -> None: + """Keep one fail-closed npm acquisition policy across every CI consumer.""" + workflow = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") + + for job_name in ( + "lock-validation", + "verify", + "rust-check", + "node-minimum-compatibility", + ): + body = _workflow_job(workflow, job_name) + assert body.count(CANONICAL_NPM_ACTIVATION) == 1, ( + f"{job_name} must delegate npm activation exactly once to the canonical helper" + ) + for duplicate_activation in ("corepack enable npm", "npm --version"): + assert duplicate_activation not in body, ( + f"{job_name} must not retain workflow-local npm activation: {duplicate_activation}" + ) + + activation_offset = body.index(CANONICAL_NPM_ACTIVATION) + install_offset = body.index("npm ci") + assert activation_offset < install_offset, ( + f"{job_name} must verify the exact npm runtime before frozen dependency admission" + ) + + +def test_repository_no_longer_advertises_node_22_13_floor() -> None: + """Canonical runtime/build documentation must not retain the superseded 22.13 floor.""" + audited_paths = ( + "package.json", + "package-lock.json", + "README.md", + "CONTRIBUTING.md", + "CLAUDE.md", + "docs/engineering/harness-engineering.md", + "docs/security/cross-platform-build-policy.md", + "docs/operations/deploy-runbook.md", + ) + + stale = [ + path + for path in audited_paths + if path != "package-lock.json" and "22.13" in (ROOT / path).read_text(encoding="utf-8") + ] + package_lock = _load_json("package-lock.json") + if package_lock["packages"][""]["engines"] != {"node": EXPECTED_NODE_ENGINE}: + stale.append("package-lock.json#packages[''].engines") + assert stale == [] diff --git a/services/analysis-engine/tests/test_npm_activation_single_path.py b/services/analysis-engine/tests/test_npm_activation_single_path.py new file mode 100644 index 000000000..802b8f28d --- /dev/null +++ b/services/analysis-engine/tests/test_npm_activation_single_path.py @@ -0,0 +1,99 @@ +"""Keep workflow npm execution on the canonical runtime activation path.""" + +from __future__ import annotations + +import re +from pathlib import Path + +import yaml + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +_CANONICAL_ACTIVATION = "bash scripts/checks/activate_pinned_npm_runtime.sh" +_SHELL_LINE_CONTINUATION = re.compile(r"\\\r?\n") +_DIRECT_NPM = re.compile( + r"(?:^|[;&|(){}]|\b(?:then|do)\b)\s*" + r"(?:!\s*)?" + r"(?:env\s+(?:[A-Za-z_][A-Za-z0-9_]*=[^\s;&|(){}]+\s+)*)?" + r"(?:[A-Za-z_][A-Za-z0-9_]*=[^\s;&|(){}]+\s+)*" + r"(?:(?:command|exec)\s+)?npm(?:\s|$)", + re.MULTILINE, +) + + +def _contains_direct_npm(script: str) -> bool: + """Detect npm after applying the shell's escaped-newline joining rule.""" + normalized = _SHELL_LINE_CONTINUATION.sub("", script) + return _DIRECT_NPM.search(normalized) is not None + + +def test_direct_npm_detection_covers_shell_control_flow_boundaries() -> None: + """Treat npm behind ordinary shell control syntax as a workflow consumer.""" + scripts = ( + "if test -f package-lock.json; then npm ci; fi", + "for attempt in 1; do npm --version; done", + "(npm ci)", + "! npm ci", + "exec npm ci", + "np\\\nm ci", + ) + + for script in scripts: + assert _contains_direct_npm(script), script + + +def test_npm_consumers_use_only_the_canonical_activation_helper() -> None: + """Reject workflow-local Corepack/runtime activation before direct npm execution.""" + workflows_dir = _REPOSITORY_ROOT / ".github" / "workflows" + workflow_paths = sorted((*workflows_dir.glob("*.yml"), *workflows_dir.glob("*.yaml"))) + assert workflow_paths + + consumers = 0 + for workflow_path in workflow_paths: + document = yaml.safe_load(workflow_path.read_text(encoding="utf-8")) + assert isinstance(document, dict) + jobs = document.get("jobs") + assert isinstance(jobs, dict) + + for job_name, job in jobs.items(): + assert isinstance(job, dict) + steps = job.get("steps") + if steps is None: + continue + assert isinstance(steps, list) + run_steps = [ + str(step["run"]) + for step in steps + if isinstance(step, dict) and isinstance(step.get("run"), str) + ] + first_npm_index = next( + (index for index, command in enumerate(run_steps) if _contains_direct_npm(command)), + None, + ) + if first_npm_index is None: + continue + + consumers += 1 + context = f"{workflow_path.name}:{job_name}" + helper_indices = [ + index + for index, command in enumerate(run_steps) + if command.strip() == _CANONICAL_ACTIVATION + ] + assert len(helper_indices) == 1, ( + f"{context} must use exactly one canonical npm activation helper" + ) + assert helper_indices[0] < first_npm_index, ( + f"{context} must activate the pinned npm runtime before direct npm execution" + ) + + for command in run_steps: + if command.strip() == _CANONICAL_ACTIVATION: + continue + assert "corepack enable npm" not in command, ( + f"{context} must not duplicate Corepack activation inline" + ) + assert "npm run check:npm-runtime" not in command, ( + f"{context} must not duplicate npm runtime verification inline" + ) + + assert consumers > 0 diff --git a/services/analysis-engine/tests/test_npm_package_manager_integrity_pin.py b/services/analysis-engine/tests/test_npm_package_manager_integrity_pin.py new file mode 100644 index 000000000..3b218c388 --- /dev/null +++ b/services/analysis-engine/tests/test_npm_package_manager_integrity_pin.py @@ -0,0 +1,31 @@ +"""Contracts for integrity-bound Corepack acquisition of the reviewed npm runtime.""" + +from __future__ import annotations + +import json +from pathlib import Path + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +_EXPECTED_PACKAGE_MANAGER = ( + "npm@10.9.9+sha512." + "d60fba8cb42f688b81e33c2f1cbef2ad7b977166700ec0ad057f1b6d60ea6ef" + "2524abf673e20c35931cd8305d1dbb8887134d6eefdc0e7b8435bd458bf65b862" +) +_EXPECTED_LOCATOR_PATTERN = r"/^npm@[0-9]+\.[0-9]+\.[0-9]+\+sha512\.[0-9a-f]{128}$/" + + +def test_root_manifest_integrity_pins_the_reviewed_npm_artifact() -> None: + """Require Corepack metadata to bind npm 10.9.9 to its reviewed SHA-512 artifact.""" + manifest = json.loads((_REPOSITORY_ROOT / "package.json").read_text(encoding="utf-8")) + + assert manifest["packageManager"] == _EXPECTED_PACKAGE_MANAGER + + +def test_activation_helper_rejects_version_only_package_manager_locators() -> None: + """Prevent an exact version from being mistaken for package-manager artifact integrity.""" + source = (_REPOSITORY_ROOT / "scripts" / "checks" / "activate_pinned_npm_runtime.sh").read_text( + encoding="utf-8" + ) + + assert _EXPECTED_LOCATOR_PATTERN in source + assert 'corepack install --global "$package_manager_spec"' in source diff --git a/services/analysis-engine/tests/test_npm_runtime_activation_nontransient_failure.py b/services/analysis-engine/tests/test_npm_runtime_activation_nontransient_failure.py new file mode 100644 index 000000000..b159194b3 --- /dev/null +++ b/services/analysis-engine/tests/test_npm_runtime_activation_nontransient_failure.py @@ -0,0 +1,154 @@ +"""Regression for non-transient npm runtime acquisition failures.""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +_ACTIVATION_HELPER = _REPOSITORY_ROOT / "scripts" / "checks" / "activate_pinned_npm_runtime.sh" +_EXPECTED_PACKAGE_MANAGER = ( + "npm@10.9.9+sha512." + "d60fba8cb42f688b81e33c2f1cbef2ad7b977166700ec0ad057f1b6d60ea6ef" + "2524abf673e20c35931cd8305d1dbb8887134d6eefdc0e7b8435bd458bf65b862" +) + + +def _write_executable(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + +def _run_corepack_failure( + tmp_path: Path, + *, + diagnostic: str, +) -> tuple[subprocess.CompletedProcess[str], Path, Path, Path, Path]: + """Run the activation helper against one deterministic Corepack failure.""" + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + count_file = tmp_path / "corepack-count.txt" + sleep_log = tmp_path / "sleep.log" + enable_log = tmp_path / "enable.log" + npm_log = tmp_path / "npm.log" + + _write_executable( + fake_bin / "node", + f"#!/usr/bin/env bash\ncat >/dev/null\nprintf '%s' {_EXPECTED_PACKAGE_MANAGER!r}\n", + ) + _write_executable( + fake_bin / "corepack", + f"""#!/usr/bin/env bash +set -euo pipefail +if [[ "$1" == "install" ]]; then + count=0 + if [[ -f "$BANDSCOPE_TEST_COREPACK_COUNT" ]]; then + count="$(cat "$BANDSCOPE_TEST_COREPACK_COUNT")" + fi + count=$((count + 1)) + printf '%s' "$count" > "$BANDSCOPE_TEST_COREPACK_COUNT" + printf '%s\\n' {diagnostic!r} >&2 + exit 1 +fi +if [[ "$1" == "enable" ]]; then + printf '%s\\n' "$*" >> "$BANDSCOPE_TEST_ENABLE_LOG" + exit 0 +fi +exit 64 +""", + ) + _write_executable( + fake_bin / "sleep", + '#!/usr/bin/env bash\nprintf \'%s\\n\' "$1" >> "$BANDSCOPE_TEST_SLEEP_LOG"\n', + ) + _write_executable( + fake_bin / "npm", + '#!/usr/bin/env bash\nprintf \'%s\\n\' "$*" >> "$BANDSCOPE_TEST_NPM_LOG"\n', + ) + + environment = os.environ.copy() + environment["PATH"] = f"{fake_bin}{os.pathsep}{environment['PATH']}" + environment["BANDSCOPE_TEST_COREPACK_COUNT"] = str(count_file) + environment["BANDSCOPE_TEST_SLEEP_LOG"] = str(sleep_log) + environment["BANDSCOPE_TEST_ENABLE_LOG"] = str(enable_log) + environment["BANDSCOPE_TEST_NPM_LOG"] = str(npm_log) + + completed = subprocess.run( + ["bash", str(_ACTIVATION_HELPER)], + cwd=tmp_path, + env=environment, + text=True, + capture_output=True, + check=False, + ) + return completed, count_file, sleep_log, enable_log, npm_log + + +def _assert_immediate_failure( + completed: subprocess.CompletedProcess[str], + count_file: Path, + sleep_log: Path, + enable_log: Path, + npm_log: Path, +) -> None: + """Require one acquisition attempt and no downstream activation work.""" + assert completed.returncode != 0 + assert count_file.read_text(encoding="utf-8") == "1" + assert not sleep_log.exists() + assert not enable_log.exists() + assert not npm_log.exists() + + +@pytest.mark.skipif( + os.name == "nt", + reason="shell helper is exercised by hosted Windows lanes", +) +def test_pinned_npm_activation_does_not_retry_signature_failure(tmp_path: Path) -> None: + """A provenance/signature failure must fail immediately instead of being retried.""" + result = _run_corepack_failure( + tmp_path, + diagnostic="Signature does not match the expected keyid", + ) + + _assert_immediate_failure(*result) + assert "Signature does not match" in result[0].stderr + + +@pytest.mark.skipif( + os.name == "nt", + reason="shell helper is exercised by hosted Windows lanes", +) +def test_pinned_npm_activation_does_not_retry_mixed_integrity_timeout_failure( + tmp_path: Path, +) -> None: + """Integrity failure wins over a timeout token in the same Corepack diagnostic.""" + result = _run_corepack_failure( + tmp_path, + diagnostic=( + "Integrity check failed while validating package-manager metadata; " + "registry request ended with ETIMEDOUT" + ), + ) + + _assert_immediate_failure(*result) + assert "Integrity check failed" in result[0].stderr + assert "not classified as transient" in result[0].stderr + + +@pytest.mark.skipif( + os.name == "nt", + reason="shell helper is exercised by hosted Windows lanes", +) +def test_pinned_npm_activation_does_not_retry_unknown_failure(tmp_path: Path) -> None: + """An unclassified Corepack failure must fail closed instead of being guessed transient.""" + result = _run_corepack_failure( + tmp_path, + diagnostic="Corepack failed while validating package-manager metadata", + ) + + _assert_immediate_failure(*result) + assert "validating package-manager metadata" in result[0].stderr + assert "not classified as transient" in result[0].stderr diff --git a/services/analysis-engine/tests/test_npm_runtime_activation_resilience.py b/services/analysis-engine/tests/test_npm_runtime_activation_resilience.py new file mode 100644 index 000000000..5a2fa99a6 --- /dev/null +++ b/services/analysis-engine/tests/test_npm_runtime_activation_resilience.py @@ -0,0 +1,243 @@ +"""Regression contracts for fail-closed npm runtime activation in hosted builds.""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest +import yaml + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +_BUILD_BASELINE = _REPOSITORY_ROOT / ".github" / "workflows" / "build-baseline.yml" +_CI_WORKFLOW = _REPOSITORY_ROOT / ".github" / "workflows" / "ci.yml" +_ACTIVATION_HELPER = _REPOSITORY_ROOT / "scripts" / "checks" / "activate_pinned_npm_runtime.sh" +_ACTIVATION_COMMAND = "bash scripts/checks/activate_pinned_npm_runtime.sh" +_EXPECTED_PACKAGE_MANAGER = ( + "npm@10.9.9+sha512." + "d60fba8cb42f688b81e33c2f1cbef2ad7b977166700ec0ad057f1b6d60ea6ef" + "2524abf673e20c35931cd8305d1dbb8887134d6eefdc0e7b8435bd458bf65b862" +) + + +def _job_steps(job: object) -> list[dict[str, object]]: + """Return structurally parsed workflow steps for one job.""" + assert isinstance(job, dict) + steps = job.get("steps") + assert isinstance(steps, list) + parsed: list[dict[str, object]] = [] + for step in steps: + assert isinstance(step, dict) + parsed.append(step) + return parsed + + +def _write_executable(path: Path, content: str) -> None: + """Create one executable fake command for helper control-flow tests.""" + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + +def _fake_command_environment( + tmp_path: Path, + *, + acquisition_failures: int, +) -> tuple[dict[str, str], Path, Path, Path, Path]: + """Return a PATH-isolated command harness and its evidence files.""" + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + corepack_count = tmp_path / "corepack-count.txt" + sleep_log = tmp_path / "sleep.log" + npm_log = tmp_path / "npm.log" + corepack_enable_log = tmp_path / "corepack-enable.log" + + _write_executable( + fake_bin / "node", + f"#!/usr/bin/env bash\ncat >/dev/null\nprintf '%s' {_EXPECTED_PACKAGE_MANAGER!r}\n", + ) + _write_executable( + fake_bin / "corepack", + """#!/usr/bin/env bash +set -euo pipefail +if [[ "$1" == "install" ]]; then + count=0 + if [[ -f "$BANDSCOPE_TEST_COREPACK_COUNT" ]]; then + count="$(cat "$BANDSCOPE_TEST_COREPACK_COUNT")" + fi + count=$((count + 1)) + printf '%s' "$count" > "$BANDSCOPE_TEST_COREPACK_COUNT" + if (( count <= BANDSCOPE_TEST_ACQUISITION_FAILURES )); then + echo 'request to registry.npmjs.org failed, reason: connect ETIMEDOUT' >&2 + exit 1 + fi + exit 0 +fi +if [[ "$1" == "enable" ]]; then + printf 'enable %s\n' "${*:2}" >> "$BANDSCOPE_TEST_COREPACK_ENABLE_LOG" + exit 0 +fi +exit 64 +""", + ) + _write_executable( + fake_bin / "sleep", + '#!/usr/bin/env bash\nprintf \'%s\\n\' "$1" >> "$BANDSCOPE_TEST_SLEEP_LOG"\n', + ) + _write_executable( + fake_bin / "npm", + '#!/usr/bin/env bash\nprintf \'%s\\n\' "$*" >> "$BANDSCOPE_TEST_NPM_LOG"\n', + ) + + environment = os.environ.copy() + environment["PATH"] = f"{fake_bin}{os.pathsep}{environment['PATH']}" + environment["BANDSCOPE_TEST_COREPACK_COUNT"] = str(corepack_count) + environment["BANDSCOPE_TEST_ACQUISITION_FAILURES"] = str(acquisition_failures) + environment["BANDSCOPE_TEST_COREPACK_ENABLE_LOG"] = str(corepack_enable_log) + environment["BANDSCOPE_TEST_SLEEP_LOG"] = str(sleep_log) + environment["BANDSCOPE_TEST_NPM_LOG"] = str(npm_log) + return environment, corepack_count, sleep_log, npm_log, corepack_enable_log + + +def _run_activation_helper( + tmp_path: Path, *, acquisition_failures: int +) -> tuple[ + subprocess.CompletedProcess[str], + Path, + Path, + Path, + Path, +]: + """Execute the real helper against deterministic fake external commands.""" + environment, corepack_count, sleep_log, npm_log, corepack_enable_log = ( + _fake_command_environment( + tmp_path, + acquisition_failures=acquisition_failures, + ) + ) + completed = subprocess.run( + ["bash", str(_ACTIVATION_HELPER)], + cwd=tmp_path, + env=environment, + text=True, + capture_output=True, + check=False, + ) + return completed, corepack_count, sleep_log, npm_log, corepack_enable_log + + +def test_build_baseline_uses_retrying_pinned_npm_activation_before_dependency_reads() -> None: + """Require every native build lane to acquire the reviewed npm runtime through one helper.""" + document = yaml.safe_load(_BUILD_BASELINE.read_text(encoding="utf-8")) + assert isinstance(document, dict) + jobs = document.get("jobs") + assert isinstance(jobs, dict) + + npm_consumers = 0 + for job_name, job in jobs.items(): + steps = _job_steps(job) + run_steps = [str(step["run"]) for step in steps if isinstance(step.get("run"), str)] + dependency_index = next( + (index for index, command in enumerate(run_steps) if command.strip() == "npm ci"), + None, + ) + if dependency_index is None: + continue + + npm_consumers += 1 + activation_indexes = [ + i for i, command in enumerate(run_steps) if command.strip() == _ACTIVATION_COMMAND + ] + assert activation_indexes == [dependency_index - 1], f"{job_name} activation ownership" + assert all("corepack enable npm" not in command for command in run_steps), ( + f"{job_name} must not perform unbounded inline Corepack activation" + ) + + assert npm_consumers == 4 + + +def test_registered_ci_exact_minimum_node_lane_uses_same_pinned_npm_activation_boundary() -> None: + """Keep the exact-minimum Node consumer on the canonical npm acquisition helper.""" + document = yaml.safe_load(_CI_WORKFLOW.read_text(encoding="utf-8")) + assert isinstance(document, dict) + jobs = document.get("jobs") + assert isinstance(jobs, dict) + assert "node-minimum-compatibility" in jobs + + steps = _job_steps(jobs["node-minimum-compatibility"]) + run_steps = [str(step["run"]) for step in steps if isinstance(step.get("run"), str)] + dependency_index = next( + index + for index, command in enumerate(run_steps) + if command.strip() == "npm ci --ignore-scripts --no-audit --no-fund" + ) + activation_indexes = [ + index for index, command in enumerate(run_steps) if command.strip() == _ACTIVATION_COMMAND + ] + + assert activation_indexes == [dependency_index - 1] + assert all("corepack enable npm" not in command for command in run_steps) + assert all("npm --version" not in command for command in run_steps) + + +def test_pinned_npm_activation_helper_retries_acquisition_but_never_falls_back() -> None: + """Keep admitted timeout recovery bounded while exact npm provenance remains fail closed.""" + source = _ACTIVATION_HELPER.read_text(encoding="utf-8") + + assert 'MAX_ATTEMPTS="3"' in source + assert 'corepack install --global "$package_manager_spec"' in source + assert '"etimedout"' in source + assert "LC_ALL=C tr '[:upper:]' '[:lower:]'" in source + assert "${acquisition_output,,}" not in source + assert "not classified as transient" in source + assert "corepack enable npm" in source + assert "npm run check:npm-runtime" in source + assert "sleep_seconds=$((attempt * 5))" in source + assert r"/^npm@[0-9]+\.[0-9]+\.[0-9]+\+sha512\.[0-9a-f]{128}$/" in source + assert "|| true" not in source + assert "npm@10.9.8" not in source + + +@pytest.mark.skipif( + os.name == "nt", + reason="shell helper is exercised by hosted Windows build lanes", +) +def test_pinned_npm_activation_recovers_after_two_transient_acquisition_failures( + tmp_path: Path, +) -> None: + """Retry admitted ETIMEDOUT acquisition, then audit the acquired npm before success.""" + completed, corepack_count, sleep_log, npm_log, corepack_enable_log = _run_activation_helper( + tmp_path, + acquisition_failures=2, + ) + + assert completed.returncode == 0, completed.stderr + assert corepack_count.read_text(encoding="utf-8") == "3" + assert sleep_log.read_text(encoding="utf-8").splitlines() == ["5", "10"] + assert npm_log.read_text(encoding="utf-8").splitlines() == ["run check:npm-runtime"] + assert corepack_enable_log.read_text(encoding="utf-8").splitlines() == ["enable npm"] + assert "ETIMEDOUT" in completed.stderr + assert f"retrying exact {_EXPECTED_PACKAGE_MANAGER}" in completed.stderr + + +@pytest.mark.skipif( + os.name == "nt", + reason="shell helper is exercised by hosted Windows build lanes", +) +def test_pinned_npm_activation_fails_closed_after_bounded_timeout_exhaustion( + tmp_path: Path, +) -> None: + """Stop after three admitted timeout failures without enabling or invoking fallback npm.""" + completed, corepack_count, sleep_log, npm_log, corepack_enable_log = _run_activation_helper( + tmp_path, + acquisition_failures=99, + ) + + assert completed.returncode != 0 + assert corepack_count.read_text(encoding="utf-8") == "3" + assert sleep_log.read_text(encoding="utf-8").splitlines() == ["5", "10"] + assert not npm_log.exists() + assert not corepack_enable_log.exists() + assert "ETIMEDOUT" in completed.stderr + assert "after 3 attempts" in completed.stderr + assert "refusing an unpinned npm fallback" in completed.stderr diff --git a/services/analysis-engine/tests/test_npm_toolchain_contract.py b/services/analysis-engine/tests/test_npm_toolchain_contract.py index f50c9adef..53aa5e397 100644 --- a/services/analysis-engine/tests/test_npm_toolchain_contract.py +++ b/services/analysis-engine/tests/test_npm_toolchain_contract.py @@ -10,9 +10,15 @@ _REPOSITORY_ROOT = Path(__file__).resolve().parents[3] _EXPECTED_NPM_VERSION = "10.9.9" +_EXPECTED_NPM_INTEGRITY = ( + "d60fba8cb42f688b81e33c2f1cbef2ad7b977166700ec0ad057f1b6d60ea6ef" + "2524abf673e20c35931cd8305d1dbb8887134d6eefdc0e7b8435bd458bf65b862" +) +_EXPECTED_PACKAGE_MANAGER = f"npm@{_EXPECTED_NPM_VERSION}+sha512.{_EXPECTED_NPM_INTEGRITY}" _EXPECTED_NODE_VERSION = "22.22.3" _MINIMUM_NPM_TAR_VERSION = "7.5.19" _NPM_RUNTIME_CHECK = "node scripts/checks/verify_npm_runtime.mjs" +_NPM_ACTIVATION_COMMAND = "bash scripts/checks/activate_pinned_npm_runtime.sh" def _root_manifest() -> dict[str, object]: @@ -86,20 +92,8 @@ def _assert_no_mutable_npm_commands(steps: list[dict[str, object]]) -> None: def _assert_patched_npm_precedes_dependency_consumption(steps: list[dict[str, object]]) -> None: - """Require Corepack npm activation and runtime audit before the first npm dependency read.""" + """Require the canonical npm activation helper before the first dependency read.""" run_steps = [str(step["run"]) for step in steps if isinstance(step.get("run"), str)] - activation_index = next( - (index for index, command in enumerate(run_steps) if "corepack enable npm" in command), - None, - ) - audit_index = next( - ( - index - for index, command in enumerate(run_steps) - if "npm run check:npm-runtime" in command - ), - None, - ) consumption_index = next( ( index @@ -108,19 +102,29 @@ def _assert_patched_npm_precedes_dependency_consumption(steps: list[dict[str, ob ), None, ) - - assert activation_index is not None - assert audit_index is not None assert consumption_index is not None - assert activation_index <= audit_index < consumption_index + + helper_indices = [ + index + for index, command in enumerate(run_steps) + if command.strip() == _NPM_ACTIVATION_COMMAND + ] + assert len(helper_indices) == 1 + assert helper_indices[0] < consumption_index + + for command in run_steps: + if command.strip() == _NPM_ACTIVATION_COMMAND: + continue + assert "corepack enable npm" not in command + assert "npm run check:npm-runtime" not in command def test_root_manifest_pins_the_lockfile_generator_and_fails_on_drift() -> None: """Require npm and source-tree commands to reject a different generator.""" manifest = _root_manifest() - assert manifest["packageManager"] == f"npm@{_EXPECTED_NPM_VERSION}" - assert manifest["engines"] == {"node": ">=22.13 <23"} + assert manifest["packageManager"] == _EXPECTED_PACKAGE_MANAGER + assert manifest["engines"] == {"node": ">=22.22.2 <23"} assert manifest["devEngines"] == { "packageManager": { "name": "npm", @@ -147,8 +151,7 @@ def test_primary_ci_consumes_the_lock_without_mutable_resolution() -> None: lock_job = _lock_validation_job(workflow) assert f'node-version: "{_EXPECTED_NODE_VERSION}"' in workflow - assert f'EXPECTED_NPM_VERSION: "{_EXPECTED_NPM_VERSION}"' in workflow - assert 'test "$(npm --version)" = "$EXPECTED_NPM_VERSION"' in lock_job + assert lock_job.count(_NPM_ACTIVATION_COMMAND) == 1 assert "npm ci --ignore-scripts --no-audit --no-fund" in lock_job assert "git diff --exit-code -- package.json package-lock.json" in lock_job assert "needs: lock-validation" in workflow @@ -211,18 +214,26 @@ def test_root_lock_preserves_esbuild_peer_metadata() -> None: def test_npm_consuming_workflows_activate_pinned_runtime_before_dependency_reads() -> None: - """Prevent dependency reads before Corepack selects and verifies the reviewed npm runtime.""" - workflow_names = ("ci.yml", "release.yml", "security-audit.yml", "build-baseline.yml") - - for workflow_name in workflow_names: - workflow_path = _REPOSITORY_ROOT / ".github" / "workflows" / workflow_name + """Discover every npm consumer and require the canonical runtime before dependency reads.""" + workflows_dir = _REPOSITORY_ROOT / ".github" / "workflows" + workflow_paths = sorted((*workflows_dir.glob("*.yml"), *workflows_dir.glob("*.yaml"))) + assert workflow_paths, "repository must contain GitHub Actions workflows" + + npm_consumer_workflows = 0 + npm_consumer_jobs = 0 + for workflow_path in workflow_paths: document = yaml.safe_load(workflow_path.read_text(encoding="utf-8")) assert isinstance(document, dict) jobs = document.get("jobs") assert isinstance(jobs, dict) - npm_consumers = 0 + workflow_consumers = 0 - for job_name in jobs: + for job_name, job in jobs.items(): + assert isinstance(job, dict) + if job.get("steps") is None: + # Reusable-workflow call jobs have no local shell steps. Their called workflow + # is scanned independently when it lives in this repository workflow directory. + continue steps = _job_steps(jobs, str(job_name)) consumes_npm = any( isinstance(step.get("run"), str) @@ -231,7 +242,8 @@ def test_npm_consuming_workflows_activate_pinned_runtime_before_dependency_reads ) if not consumes_npm: continue - npm_consumers += 1 + workflow_consumers += 1 + npm_consumer_jobs += 1 _assert_checkout_credentials_not_persisted(steps) setup_node_steps = [ @@ -240,15 +252,18 @@ def test_npm_consuming_workflows_activate_pinned_runtime_before_dependency_reads if isinstance(step.get("uses"), str) and str(step["uses"]).startswith("actions/setup-node@") ] - assert len(setup_node_steps) == 1, f"{workflow_name}:{job_name} setup-node ownership" + context = f"{workflow_path.name}:{job_name}" + assert len(setup_node_steps) == 1, f"{context} setup-node ownership" setup_options = setup_node_steps[0].get("with") assert isinstance(setup_options, dict) - assert "cache" not in setup_options, ( - f"{workflow_name}:{job_name} pre-Corepack npm cache" - ) + assert "cache" not in setup_options, f"{context} pre-Corepack npm cache" assert setup_options.get("package-manager-cache") is False, ( - f"{workflow_name}:{job_name} must disable setup-node package-manager cache" + f"{context} must disable setup-node package-manager cache" ) _assert_patched_npm_precedes_dependency_consumption(steps) - assert npm_consumers > 0, f"{workflow_name} must contain an npm dependency consumer" + if workflow_consumers: + npm_consumer_workflows += 1 + + assert npm_consumer_workflows > 0, "repository must contain an npm-consuming workflow" + assert npm_consumer_jobs > 0, "repository must contain an npm-consuming workflow job" diff --git a/services/analysis-engine/tests/test_supply_chain_policy.py b/services/analysis-engine/tests/test_supply_chain_policy.py index 1d8224c5a..6a0853944 100644 --- a/services/analysis-engine/tests/test_supply_chain_policy.py +++ b/services/analysis-engine/tests/test_supply_chain_policy.py @@ -1275,9 +1275,7 @@ def test_workflow_concurrency_cancels_only_superseded_pr_heads() -> None: workflow = (workflows_dir / workflow_name).read_text(encoding="utf-8") assert "concurrency:" in workflow, workflow_name assert "cancel-in-progress: false" in workflow, workflow_name - assert "contents: read" in workflow or "permissions: read-all" in workflow, ( - workflow_name - ) + assert "contents: read" in workflow or "permissions: read-all" in workflow, workflow_name assert "pull_request:" not in (workflows_dir / "release.yml").read_text(encoding="utf-8")