diff --git a/.github/workflows/acquisition-readiness-scan.yml b/.github/workflows/acquisition-readiness-scan.yml index 97a19b615..5341eb32f 100644 --- a/.github/workflows/acquisition-readiness-scan.yml +++ b/.github/workflows/acquisition-readiness-scan.yml @@ -26,15 +26,15 @@ jobs: - name: setup node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "24.19.0" + node-version: "24.21.0" cache: npm - name: verify package-manager toolchain shell: bash run: | set -euo pipefail - test "$(node --version)" = "v24.19.0" - test "$(npm --version)" = "11.17.0" + test "$(node --version)" = "v24.21.0" + test "$(npm --version)" = "11.19.0" - name: verify tracked acquisition source before evidence generation shell: bash diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 6e8d67388..4aaf7f43e 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -64,15 +64,15 @@ jobs: - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "24.19.0" + node-version: "24.21.0" cache: npm - name: Verify deployment package-manager toolchain shell: bash run: | set -euo pipefail - test "$(node --version)" = "v24.19.0" - test "$(npm --version)" = "11.17.0" + test "$(node --version)" = "v24.21.0" + test "$(npm --version)" = "11.19.0" - name: Verify immutable release and exact source identity id: identity diff --git a/.github/workflows/central-review.yml b/.github/workflows/central-review.yml index dae49d9bd..9c4cd938a 100644 --- a/.github/workflows/central-review.yml +++ b/.github/workflows/central-review.yml @@ -491,6 +491,7 @@ jobs: PYTHONPATH: ${{ github.workspace }}/reviewer:${{ github.workspace }}/packages/noema-core/src NOEMA_REVIEW_TOKEN_SOURCE: noema-github-app NOEMA_LLM_API_URL: ${{ vars.NOEMA_LLM_API_URL }} + NOEMA_LLM_API_URL_ALLOWLIST_JSON: ${{ vars.NOEMA_LLM_API_URL_ALLOWLIST_JSON }} NOEMA_LLM_MODEL: orchestrator/free # Dedicated inference token for contextual-orchestrator. Upstream # provider credentials stay inside the orchestrator credential KV. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb976be14..5d6f903d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,15 +44,15 @@ jobs: - name: setup node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "24.19.0" + node-version: "24.21.0" cache: npm - name: verify package-manager toolchain shell: bash run: | set -euo pipefail - test "$(node --version)" = "v24.19.0" - test "$(npm --version)" = "11.17.0" + test "$(node --version)" = "v24.21.0" + test "$(npm --version)" = "11.19.0" - name: verify live pull-request base before lockfile control if: github.event_name == 'pull_request' diff --git a/.github/workflows/hourly-commercial-readiness.yml b/.github/workflows/hourly-commercial-readiness.yml index 35113eac8..b28113551 100644 --- a/.github/workflows/hourly-commercial-readiness.yml +++ b/.github/workflows/hourly-commercial-readiness.yml @@ -135,15 +135,15 @@ jobs: - name: setup node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "24.19.0" + node-version: "24.21.0" cache: npm - name: verify package-manager toolchain shell: bash run: | set -euo pipefail - test "$(node --version)" = "v24.19.0" - test "$(npm --version)" = "11.17.0" + test "$(node --version)" = "v24.21.0" + test "$(npm --version)" = "11.19.0" - name: install lockfile dependencies run: npm ci --legacy-peer-deps=false --install-links=false diff --git a/.github/workflows/hourly-product-development.yml b/.github/workflows/hourly-product-development.yml index f5d1b5b1f..7f2b1d9de 100644 --- a/.github/workflows/hourly-product-development.yml +++ b/.github/workflows/hourly-product-development.yml @@ -54,6 +54,7 @@ jobs: GH_TOKEN: ${{ github.token }} ORCHESTRATOR_KEY_CONFIGURED: ${{ secrets.NOEMA_LLM_API_KEY != '' }} ORCHESTRATOR_URL_CONFIGURED: ${{ vars.NOEMA_LLM_API_URL != '' }} + ORCHESTRATOR_ALLOWLIST_CONFIGURED: ${{ vars.NOEMA_LLM_API_URL_ALLOWLIST_JSON != '' }} MAINTAINER_APP_CLIENT_ID_CONFIGURED: ${{ vars.NOEMA_MAINTAINER_APP_CLIENT_ID != '' }} MAINTAINER_APP_PRIVATE_KEY_CONFIGURED: ${{ secrets.NOEMA_MAINTAINER_APP_PRIVATE_KEY != '' }} run: | @@ -82,7 +83,8 @@ jobs: fi if { [ "$ORCHESTRATOR_KEY_CONFIGURED" != "true" ] \ - || [ "$ORCHESTRATOR_URL_CONFIGURED" != "true" ]; } \ + || [ "$ORCHESTRATOR_URL_CONFIGURED" != "true" ] \ + || [ "$ORCHESTRATOR_ALLOWLIST_CONFIGURED" != "true" ]; } \ && [ "$DRY_RUN" != "true" ]; then { echo "dispatch=false" @@ -245,6 +247,7 @@ jobs: shell: bash env: NOEMA_LLM_API_URL: ${{ vars.NOEMA_LLM_API_URL }} + NOEMA_LLM_API_URL_ALLOWLIST_JSON: ${{ vars.NOEMA_LLM_API_URL_ALLOWLIST_JSON }} NOEMA_LLM_MODEL: orchestrator/free run: | set -euo pipefail diff --git a/.github/workflows/maintainer-app-readiness.yml b/.github/workflows/maintainer-app-readiness.yml index 532581764..857fd1fa8 100644 --- a/.github/workflows/maintainer-app-readiness.yml +++ b/.github/workflows/maintainer-app-readiness.yml @@ -66,13 +66,13 @@ jobs: - name: setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "24.19.0" + node-version: "24.21.0" - name: verify Node.js runtime shell: bash run: | set -euo pipefail - test "$(node --version)" = "v24.19.0" + test "$(node --version)" = "v24.21.0" - name: audit active main governance id: governance diff --git a/.github/workflows/patch-validator-image.yml b/.github/workflows/patch-validator-image.yml index fd37c4ada..c0b21ee4c 100644 --- a/.github/workflows/patch-validator-image.yml +++ b/.github/workflows/patch-validator-image.yml @@ -100,10 +100,10 @@ jobs: url="$2" timeout --signal=TERM --kill-after=30s 5m \ curl --proto '=https' --proto-redir '=https' --tlsv1.2 --location --fail --silent --show-error \ - --retry 3 \ + --retry 7 \ --retry-all-errors \ - --retry-delay 2 \ - --retry-max-time 90 \ + --retry-delay 0 \ + --retry-max-time 240 \ --connect-timeout 20 \ --max-time 180 \ --output "$destination" "$url" @@ -138,15 +138,15 @@ jobs: - name: Set up exact dependency materialization toolchain uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "24.19.0" + node-version: "24.21.0" cache: npm - name: Materialize exact patch-validator dependencies shell: bash run: | set -euo pipefail - test "$(node --version)" = "v24.19.0" - test "$(npm --version)" = "11.17.0" + test "$(node --version)" = "v24.21.0" + test "$(npm --version)" = "11.19.0" deps_dir="$RUNNER_TEMP/patch-validator-deps" rm -rf "$deps_dir" @@ -220,7 +220,7 @@ jobs: shell: bash run: | set -euo pipefail - test "$(docker run --rm --pull=never --entrypoint=/nodejs/bin/node "$IMAGE_TAG" --version)" = "v24.19.0" + test "$(docker run --rm --pull=never --entrypoint=/nodejs/bin/node "$IMAGE_TAG" --version)" = "v24.21.0" - name: Inspect exact image metadata and exclude dynamic runtime payloads shell: bash diff --git a/.github/workflows/private-vulnerability-reporting-audit.yml b/.github/workflows/private-vulnerability-reporting-audit.yml index cb4cc0fac..17bd64458 100644 --- a/.github/workflows/private-vulnerability-reporting-audit.yml +++ b/.github/workflows/private-vulnerability-reporting-audit.yml @@ -33,7 +33,7 @@ jobs: - name: setup node without repository token uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "24.19.0" + node-version: "24.21.0" token: "" - name: verify exact checkout @@ -93,7 +93,7 @@ jobs: - name: setup node without repository token uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "24.19.0" + node-version: "24.21.0" token: "" - name: verify exact checkout diff --git a/.github/workflows/readiness-scan.yml b/.github/workflows/readiness-scan.yml index 122bee81b..6571eed5f 100644 --- a/.github/workflows/readiness-scan.yml +++ b/.github/workflows/readiness-scan.yml @@ -33,15 +33,15 @@ jobs: - name: setup node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "24.19.0" + node-version: "24.21.0" cache: npm - name: verify package-manager toolchain shell: bash run: | set -euo pipefail - test "$(node --version)" = "v24.19.0" - test "$(npm --version)" = "11.17.0" + test "$(node --version)" = "v24.21.0" + test "$(npm --version)" = "11.19.0" - name: install run: npm ci --legacy-peer-deps=false --install-links=false diff --git a/.github/workflows/release-evidence.yml b/.github/workflows/release-evidence.yml index 8621da117..936fa311e 100644 --- a/.github/workflows/release-evidence.yml +++ b/.github/workflows/release-evidence.yml @@ -65,15 +65,15 @@ jobs: - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "24.19.0" + node-version: "24.21.0" cache: npm - name: Verify package-manager toolchain shell: bash run: | set -euo pipefail - test "$(node --version)" = "v24.19.0" - test "$(npm --version)" = "11.17.0" + test "$(node --version)" = "v24.21.0" + test "$(npm --version)" = "11.19.0" - name: Verify tag, commit, and package version binding id: identity @@ -154,15 +154,15 @@ jobs: - name: Set up Node.js for sterile materialization uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "24.19.0" + node-version: "24.21.0" cache: npm - name: Verify sterile materialization toolchain and source identity shell: bash run: | set -euo pipefail - test "$(node --version)" = "v24.19.0" - test "$(npm --version)" = "11.17.0" + test "$(node --version)" = "v24.21.0" + test "$(npm --version)" = "11.19.0" test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT_SHA" test "$(git rev-parse "${RELEASE_TAG}^{commit}")" = "$RELEASE_COMMIT_SHA" package_version="$(node --input-type=module -e "import packageJson from './package.json' with { type: 'json' }; process.stdout.write(packageJson.version)")" diff --git a/AGENTS.md b/AGENTS.md index 86205e121..e2fc9e010 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,6 +92,11 @@ Worker (npm + `wrangler.toml`); tests run under Vitest. `NOEMA_LLM_MODEL` is the canonical routing alias `orchestrator/free` (fail-closed zero-cost pool, ZDR-first), and `NOEMA_LLM_API_KEY` is a dedicated gateway inference token. +- `NOEMA_LLM_API_URL_ALLOWLIST_JSON` is a non-empty JSON array of exact + released contextual-orchestrator `/v1` endpoints. The selected URL must be + an exact member before Noema emits credential-bearing configuration. Array + order never selects, retries, or falls back across endpoints; `/healthz` + self-identification is liveness evidence, not endpoint authority. - The reusable, secret-free copy is `contracts/orchestrator-gateway.json` (`node scripts/verify-orchestrator-gateway.mjs --print-contract`). Narrative: `docs/orchestrator-gateway-consumer-contract.md`. Validation helpers live in diff --git a/CHANGELOG.md b/CHANGELOG.md index f2aff108f..b2ffd51c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,15 @@ # Changelog +- Require every Noema model consumer to admit `NOEMA_LLM_API_URL` by exact + membership in the released contextual-orchestrator endpoint set before + health I/O or gateway-token attachment. The JSON array is an unordered + admission set, not a routing/fallback list; missing, malformed, duplicate, + wildcard, plaintext, direct-provider, or non-member endpoints fail closed. + - Add a Noema-owned exact-claim evidence receipt contract whose execution and research producers serialize one canonical artifact that binds every receipt semantic field, including command/result/isolation/network or source revision/excerpt/retrieval policy. Admission accepts only a receipt ID from untrusted model output. The owner API first verifies the exact authenticated OpenCode-handoff manifest digest, canonical envelope bytes, reviewed producer-to-kind policy, and repository/head/workflow/run/attempt identity before it can construct an immutable typed index; admission then reconstructs each canonical artifact and verifies time/claim/artifact identity. The version-2 manifest now binds a separate producer-authenticated `ClaimEvidenceRequirement` containing the exact claim, independently required evidence kind, and `context` or `finding` publication authority. Raw current-head source lines are context only: they are withheld from finding-reference prompts and cannot publish a finding or `request_changes`; an explicitly producer-authorized source finding remains usable and retains exact path/line checks. Finding-free model `request_changes` and `blocked` verdicts cannot bypass receipt admission to publish a vacuous blocking review. Requirement/receipt kind mismatch, fixed-artifact semantic substitution, caller-supplied receipt dictionaries, model self-classification, stale identities, cross-kind receipts, marker-only sandbox output, noncanonical artifact bytes, and expired receipts fail closed before the GitHub publisher. This remains the owner prerequisite for ContextualWisdomLab/.github#1641 and issue #555. The reviewed `sandboxed_verify` adapter exists in owner source, but its actual central stdout/stderr/marker-to-manifest wiring and the trusted research producer are not yet integrated; exact-head hosted GREEN, immutable release, and the verified central consumer bump remain required. ## Unreleased +- PR #737의 patch-validator static-runtime receipt verifier가 `process.versions.node`뿐 아니라 bundled Undici를 reviewed fixed version `7.29.1`로 exact-match한다. 서로 내부적으로 일치하더라도 Undici `7.29.0` inventory/component/PURL/raw scan evidence는 fail closed하며, simplified verifier fixtures도 실제 Node 24.21.0 runtime identity에 맞춘다. Source/test GREEN은 새 exact-head hosted image/SBOM/provenance/scanner, 독립 승인, ordinary merge, immutable release를 대신하지 않는다. - `packages/noema-core/README.md`의 `build_agent` 서명을 코드와 ADR-0014에 맞춘다. caller-visible `retries=3` 표기를 제거하고, 이 경계의 model-attempt retry는 `Agent(..., retries=0)`으로 고정되며 orchestration retry/failover는 contextual-orchestrator 소유임을 명시한다. 구현·ADR 상태·provider 정책은 바꾸지 않는다. - PR #724 makes private-vulnerability-reporting setting receipts a fail-closed strict-release prerequisite without turning setting evidence into release or deployment truth. The consumer validates bounded stable/no-follow receipt files with fatal UTF-8 and duplicate-key rejection, canonical repository/source/timestamp identity, bounded future skew and freshness, and explicit `PASS + enabled:true + failures:[]`; all observations are validated before ordering and the newest observation wins even when it is FAIL, so a historical PASS cannot mask later contradictory evidence. Protected CD recollects the setting in the same job from the exact immutable-release commit through a repository-scoped `Metadata: read` GitHub App capability, moves the short-lived bearer into an owner-only capability file, unsets the raw bootstrap value before Node execution, feeds the same-run receipt into `release:verify:strict`, and retains the receipt with deployment evidence. The ordinary pre-tag source gate remains `release:verify`; bare pre-publication `release:verify:strict` without a fresh matching receipt is not an authorized procedure. External reporter visibility, staffing/notification, benign private-case handling, immutable-release publication, deployment, KPI and acquisition authority remain independent evidence classes. - Protected #719 exact `de91e24fdac66c9963c92ca77971d870a48778e4`, integrated by GitHub-verified normal merge `4433c3009d4c6bc900bf5c8346f75b07185ff985`, hardens the Noema Policy / Approval procedural ledger without widening its authority. Complete retained approval-chain cryptographic verification and immutable next-event SHA-256 derivation occur before `DurableObjectStorage.transaction()`; append and exact replay then re-read current retained approval state inside the short transaction, require canonical root/event/dense-array structured-clone shape, compare the complete current retained structure with the exact preverified structure using field-by-field/`Object.is` equality, and perform only exact replay return or one durable write. Runtime-untrusted retained identity/digest values must already be canonical strings before regex/hash use, so coercible non-string lookalikes such as `BigInt` fail closed as Policy / Approval conflicts rather than becoming authority through JavaScript coercion. Hostile regressions cover transaction-active digest instrumentation, a legal interleaving winner, post-verification internal event drift, JSON-invisible unknown fields on append/replay, sparse or compensating arrays, coercible non-string digest/identity values, and event-array cardinality drift. Exact decision replay, approve/revoke state-machine rules, history binding, capacity, stale-writer rejection and `activationAuthorized:false` remain unchanged; this protected source/check/review/merge evidence is not an immutable release, deployed Durable Object recovery/contention result, production p95/heap evidence, graph publication, or activation authority. @@ -90,14 +97,14 @@ - strict production KPI provenance를 fail-closed로 강화한다. `collectedAt`은 canonical UTC 시각만 허용하고 미래·존재하지 않는 달력값을 거부하며, `records`는 coercion 없는 양의 safe-integer JSON number로 authenticated log record count와 일치해야 한다. production log는 `O_NOFOLLOW`로 연 verified regular-file descriptor의 identity·size·mode·mtime·ctime을 재검증하면서 동일 verified bytes로 snapshot을 생성해 symlink·pathname replacement·descriptor drift가 KPI authority를 제조하지 못하게 한다. - 인수 준비성 감사의 buyer/data-room 증거 경계를 실패-폐쇄로 강화한다. `updated_at`은 canonical ISO date 또는 timezone-bearing timestamp만 허용하고 존재하지 않는 달력값·비정규 형식·미래 시각을 거부하며, 명시된 evidence max-age 설정과 매출·고객·LOI·비율 지표는 coercion 없이 유효 도메인을 만족해야 한다. immutable release asset의 `bytes`도 실제 양의 safe-integer JSON number만 인정해 문자열·범위 밖 값이 매각 준비 증거를 제조하지 못하게 한다. - 루트 `README.md`를 구매자/운영자 표면으로 재작성한다. 제품 정의, 단독 실행, 호스트 호출(`/health`·`/ready`·`/exchange`, `contracts/orchestrator-gateway.json`), GitHub App 권한, 운영자가 설정하는 Worker 변수, naruon·곁(gyeot) 허브-리프 호출을 앞에 둔다. 시간당 상업 루프, 시간당 제품 개발 절차, 매각/판매 가능 프로그램, KPI provenance 수집 레시피, readiness-scan 내부는 README에서 빼고 `docs/internal/README.md`로 안내한다. CloudAgent/OpenCode 절차, PR 스택, exact-head CI, writer/agent 경계는 기존 `CONTRIBUTING.md`와 `docs/development/contributor-and-agent-procedure.md`에 남기며 삭제하지 않는다. `test/readme-customer-facing.test.ts`가 README에 봇 매뉴얼 잔여가 남지 않는지 고정한다. -- repository-owned patch-validator image는 Node.js 24.19.0을 fully static으로 빌드한 `scratch` runtime을 사용하고, `process.versions` 기반 embedded dependency inventory, exact image SHA-256, CycloneDX/Syft/Grype/Trivy evidence, no-network/read-only/non-root smoke를 동일 exact-head receipt에 결합한다. +- repository-owned patch-validator image는 `scratch` runtime을 유지하면서 Node.js 24.21.0/npm 11.19.0으로 올라 bundled Undici 7.29.0의 GHSA-3wwx-pv8p-q78v를 fixed 7.29.1로 교체한다. Docker build와 trusted `process.versions` inventory가 `undici === "7.29.1"`을 exact-match하고, 기존 c-ares/OpenSSL overlay, exact image SHA-256, CycloneDX/Syft/Grype/Trivy evidence, no-network/read-only/non-root smoke를 동일 exact-head receipt에 유지한다. - 비리뷰 LLM 작업인 `hourly-product-development`를 리뷰와 동일한 `contextual-orchestrator` 게이트웨이 계약(`NOEMA_LLM_API_URL` `/v1`, 모델 별칭 `contextual-orchestrator`, 전용 `NOEMA_LLM_API_KEY`)으로 전환한다. Llama Nemotron → Nemotron Super → DeepSeek 순차 NIM 후보 폴백과 `NVIDIA_NIM_API_KEY` 직접 호출을 제거하고, 공유 `scripts/verify-orchestrator-gateway.mjs`가 `/healthz` 신원과 직접 공급자 호스트를 실패-폐쇄한다. 리뷰어의 `NOEMA_FALLBACK_*` / PydanticAI `FallbackModel` 순차 폴백도 제거해 남은 설정은 실패-폐쇄한다. 동일 계약을 `contracts/orchestrator-gateway.json`으로 공개해 `ContextualWisdomLab/naruon` 판단·결정 에이전트가 1급 소비자로 재사용할 수 있게 한다. naruon 배선은 별도 저장소 PR이다. 상위 공급자 키는 오케스트레이터 KV에 남기며 OIDC 토큰 중개·App 신원·3-runner 샌드박스 경계는 유지한다. - 검증된 active-orphan 워크플로 하나를 운영자가 호출할 수 있는 `operations:workflow-registry-disable` 경로를 추가한다. 저장소와 워크플로 ID를 `NOEMA_MAINTAINER_TOKEN_PATH` 위임 토큰 파일 읽기 전에 검사하고, 신선한 전체 레지스트리 감사·즉시 live refresh·프로세스 로컬 plan·보호된 main/워크플로 재검증·사후 전체 감사 봉투(`schema_version` 1, `PASS`/`FAIL`, `remaining_failure_codes`, `remaining_active_orphan_ids`)를 통과한 뒤에만 영수증을 유지한다. 성공 종료와 `post_audit_status: FAIL`은 해당 ID만 `disabled_manually`가 되었고 레지스트리는 아직 더러울 수 있음을 뜻하므로, 운영자는 영수증의 `remaining_active_orphan_ids`로 다음 단일 호출을 이어간다. 배치 비활성화·자가 수리 워크플로·거버넌스 완화는 추가하지 않으며 호출 계약은 doctoring에 기록한다. - 읽기 전용 `operations:runner-assignment` audit를 추가해 exact workflow run/source head에 대한 runner assignment를 완전 pagination으로 진단하고, 신선한 unassigned queue는 bounded grace 이후 실패-폐쇄한다. 이 증빙은 runner assignment와 required Check/CI, formal review, merge, release, deployment authority를 분리하며 assigned runner 이후 workflow failure를 성공으로 승격하지 않는다. - production `operations:runner-assignment` audit는 `NOEMA_MAINTAINER_TOKEN_PATH`의 owner-only capability file만 읽고, ambient `GH_TOKEN`만 있으면 실패-폐쇄한다. `gh` spawn/stderr 진단은 활성 토큰을 exact-match로 `[REDACTED]` 치환하며, 빈 secret에 대해서는 원문 진단을 보존한다. assignment authority는 양의 `runner_id` 또는 비어 있지 않은 `runner_name`만 인정하며 queued `started_at`은 assignment evidence가 아니다. 운영자는 `printf '%s'`로 capability file을 만들고(`echo`/`printf '%s\n'`는 trailing newline 때문에 실패-폐쇄), Actions workflow-run/job read만 가진 짧은 토큰을 준비한 뒤 PASS를 required Check·formal review·merge 권한으로 해석하지 마십시오. - coordinated vulnerability disclosure 정책과 evidence-preserving vulnerability handling lifecycle, read-only private-vulnerability-reporting setting audit를 추가한다. 이 source 변경은 live private reporting 활성화·notification staffing·end-to-end advisory exercise·release/deployment authority를 증명하지 않는다. - 개발 의존성 체인의 transitive `nanoid` lockfile resolution을 `3.3.17`에서 `3.3.18`로 최소 갱신하여 GHSA-2v37-7h3g-55p8 / CVE-2026-67213 보안 게이트를 복구한다. PostCSS의 선언 범위 `^3.3.16`과 다른 package metadata는 변경하지 않으며 audit waiver·ignore·severity 완화 없이 `npm ci`/`npm audit --audit-level=high`가 exact head에서 재검증되도록 유지한다. -- lockfile 재생성 도구 체인을 Node.js 24.19.0/npm 11.17.0으로 정확히 고정하고, `strict-allow-scripts=true` 아래 승인된 install-script identity만 실행하며 schema v3 exact-base lockfile change control로 package metadata drift를 실패-폐쇄한다. exact package before/after digest에 더해 top-level metadata digest와 대규모 package-set bulk evidence를 결합하며, 선행 `nanoid@3.3.18` 보안 수정과 explicit `npm ci --legacy-peer-deps=false --install-links=false` 계약을 보존한다. package-manager/toolchain·install-script authority·vulnerability audit·review/merge authority는 별도 증거 계층으로 유지한다. +- lockfile 재생성 도구 체인을 Node.js 24.21.0/npm 11.19.0으로 정확히 고정하고, `strict-allow-scripts=true` 아래 승인된 install-script identity만 실행하며 schema v3 exact-base lockfile change control로 package metadata drift를 실패-폐쇄한다. exact package before/after digest에 더해 top-level metadata digest와 대규모 package-set bulk evidence를 결합하며, 선행 `nanoid@3.3.18` 보안 수정과 explicit `npm ci --legacy-peer-deps=false --install-links=false` 계약을 보존한다. package-manager/toolchain·install-script authority·vulnerability audit·review/merge authority는 별도 증거 계층으로 유지한다. - `hourly-product-development`가 `NVIDIA_NIM_API_KEY`뿐 아니라 `NOEMA_MAINTAINER_APP_CLIENT_ID`와 `NOEMA_MAINTAINER_APP_PRIVATE_KEY` 존재를 checkout·OpenCode 설치·NVIDIA 호출 전에 검증한다. 게시 경로가 준비되지 않았으면 `maintainer_app_unavailable`로 실패 폐쇄하여 알려진 실패에 추론 비용을 쓰지 않으며, `dry_run`은 credential 없이 queue와 task contract를 검토하는 경로로 유지한다. 기존 reviewer App 및 `NOEMA_LLM_API_KEY`·`contextual-orchestrator` reviewer credential 경계는 변경하지 않는다. - zero open pull requests일 때만 `NVIDIA_NIM_API_KEY` 전용 OpenCode 1.17.13 세션을 실행하는 proposal-only `hourly-product-development` 루프를 추가. minute-47 schedule·non-cancelling single flight·OpenCode binary SHA-256 pin·NVIDIA NIM model fallback·후보 실패 시 clean reset·GitHub/OIDC credential 제거·reviewer key 비참조·full release verification·40-file/500,000-byte proposal budget·trusted one-PR packaging을 강제한다. 각 후보 실행은 900초와 30초 kill grace로 제한하고, 실패 후 `npm ci --ignore-scripts` 재설치는 별도 60초와 10초 kill grace로 제한한다. 재설치가 실패하거나 시간 초과되면 불완전한 dependency tree로 다음 후보를 실행하지 않고 실패 폐쇄한다. 세 후보의 실행·종료 2,790초, 두 번의 후보 간 재설치 140초, 300초 setup/diagnostic reserve를 합친 3,230초가 55분(3,300초) job budget에 들어가며 70초 여유를 남긴다. 마지막 후보가 실패하면 불필요한 reset·clean·재설치를 생략하고 안정적인 전체 후보 실패 진단으로 곧바로 종료한다. 모델 실행, 제안 코드 검증, publication credential을 각각 별도의 GitHub-hosted runner로 분리하고, immutable artifact의 exact ID·workflow-run ID·archive digest와 patch SHA-256·base SHA·file/byte count를 교차 검증하며 symlink(`120000`)와 gitlink(`160000`)를 세 경계 모두에서 차단한다. 제안 코드를 실행한 runner에는 Maintainer App secret/token을 절대 제공하지 않고, 세 번째 non-executing publisher에서만 late-bound repository-scoped App token을 발급한다. merge/release/deploy authority는 기존 `hourly-commercial-readiness` exact-head governance에 유지하며, 운영 Runbook과 OpenCode/NVIDIA/GitHub Actions/NIST SP 800-218 근거를 APA 7th doctoring에 기록했다. package version은 release·deployment·production KPI evidence를 발행하지 않으므로 유지한다. - `/health` liveness와 분리된 unauthenticated `GET`/`HEAD /ready` runtime readiness endpoint를 추가. GitHub Actions OIDC issuer·audience·organization/workflow binding·exact workflow ref·GitHub Cloud API origin·GitHub App identifiers·PKCS#8 private key를 외부 호출 없이 검증하며, 불완전한 설정은 secret/config value를 반사하지 않는 deterministic failure codes와 `503 ERR_SERVICE_NOT_READY`, `Retry-After`, no-store/nosniff/trace/latency headers로 실패-폐쇄한다. exact workflow named ref는 Git `check-ref-format`의 모호성·유효성 경계(`..`, `//`, dot-leading/`.lock` component, revision-expression 문자, trailing dot/slash 등)를 만족해야 하므로 GitHub가 실제로 표현할 수 없는 ref에서 false-ready가 발생하지 않는다. 배포 smoke contract가 liveness·runtime readiness·unauthenticated exchange challenge를 모두 요구하도록 확장하고 Kubernetes probe separation, RFC 9110, NIST SSDF, Git ref-format 근거를 APA 7th doctoring에 기록했다. @@ -152,4 +159,4 @@ - `/exchange` 401 응답에 `WWW-Authenticate: Bearer realm="noema"` challenge를 추가하고 인증 누락은 `invalid_request`, 잘못된 토큰은 `invalid_token`으로 구분. - `x-request-id`/`x-correlation-id` 및 client IP 계열 헤더를 길이/문자 기준으로 제한해 로그 오염과 rate-limit key 폭주를 방지. - `KRW 2,000,000,000` 매각 가능성 Goal 등록서, buyer due diligence index, library/submodule 경계 판단서를 추가하고 `npm run acquisition:audit`로 ARR/LOI/이전성/saleable evidence를 실패-폐쇄 방식으로 검증. -- Protected #695 normalizes synchronous reader acquisition on the contextual-orchestrator `/healthz` response consumer at exact source `c9d526093e543167645383e937096fd089b337f8`. If a locked or otherwise non-stream-readable `/healthz` response body makes `response.body.getReader()` throw synchronously, Noema fails closed with `contextual-orchestrator health response body is not stream-readable` instead of leaking a runtime-specific stream exception. The existing 65,536-byte ceiling, optional caller-supplied deadline with no invented default timeout, fixed retained buffer, best-effort cancellation, fatal UTF-8, duplicate-key/JSON admission, and reader-lock lifecycle after successful acquisition remain unchanged. Contextual-orchestrator service/provider/model routing, credentials, outbound authority, quarantine/security authority, release/deployment authority, and foreign-domain truth remain with their existing owners; protected source is not an immutable release or deployed availability/p95/recovery evidence. PR #695. \ No newline at end of file +- Protected #695 normalizes synchronous reader acquisition on the contextual-orchestrator `/healthz` response consumer at exact source `c9d526093e543167645383e937096fd089b337f8`. If a locked or otherwise non-stream-readable `/healthz` response body makes `response.body.getReader()` throw synchronously, Noema fails closed with `contextual-orchestrator health response body is not stream-readable` instead of leaking a runtime-specific stream exception. The existing 65,536-byte ceiling, optional caller-supplied deadline with no invented default timeout, fixed retained buffer, best-effort cancellation, fatal UTF-8, duplicate-key/JSON admission, and reader-lock lifecycle after successful acquisition remain unchanged. Contextual-orchestrator service/provider/model routing, credentials, outbound authority, quarantine/security authority, release/deployment authority, and foreign-domain truth remain with their existing owners; protected source is not an immutable release or deployed availability/p95/recovery evidence. PR #695. diff --git a/CLAUDE.md b/CLAUDE.md index 41d3c2bd9..c6da2c99b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What noema is -Noema is ContextualWisdomLab's multi-purpose GitHub App bot. The Cloudflare Worker (Free tier) remains the OIDC token broker: GitHub Actions presents a GitHub OIDC token (audience `cwl-noema-review`), noema verifies issuer/audience/org owner/trusted central workflow identity, then exchanges it for a GitHub App installation token scoped to the target repository with minimal permissions (`pull_requests: write`, `contents: read`, `checks: read`). Review is one job, not the only job. Noema also runs as a separate agent program inside `ContextualWisdomLab/naruon` for judgments and decisions; naruon is a first-class consumer of the same gateway contract (wiring is a separate naruon PR). Every LLM path — production review, hourly product development, and naruon judgments — calls `contextual-orchestrator` (`NOEMA_LLM_API_URL` ending in `/v1`, model pinned to the canonical routing alias `orchestrator/free` — the fail-closed zero-cost ZDR-first pool, not the paid-inclusive full pool — dedicated `NOEMA_LLM_API_KEY`). The reusable contract is `contracts/orchestrator-gateway.json`. Noema does not sequentially try the next model or hold upstream provider keys. +Noema is ContextualWisdomLab's multi-purpose GitHub App bot. The Cloudflare Worker (Free tier) remains the OIDC token broker: GitHub Actions presents a GitHub OIDC token (audience `cwl-noema-review`), noema verifies issuer/audience/org owner/trusted central workflow identity, then exchanges it for a GitHub App installation token scoped to the target repository with minimal permissions (`pull_requests: write`, `contents: read`, `checks: read`). Review is one job, not the only job. Noema also runs as a separate agent program inside `ContextualWisdomLab/naruon` for judgments and decisions; naruon is a first-class consumer of the same gateway contract (wiring is a separate naruon PR). Every LLM path — production review, hourly product development, and naruon judgments — calls `contextual-orchestrator` (`NOEMA_LLM_API_URL` ending in `/v1` and exactly present in the released endpoint set carried by `NOEMA_LLM_API_URL_ALLOWLIST_JSON`, model pinned to the canonical routing alias `orchestrator/free` — the fail-closed zero-cost ZDR-first pool, not the paid-inclusive full pool — dedicated `NOEMA_LLM_API_KEY`). The reusable contract is `contracts/orchestrator-gateway.json`. A self-asserted `/healthz` response is liveness evidence, not endpoint identity authority. Noema does not sequentially try the next model or hold upstream provider keys. ## Commands @@ -60,4 +60,4 @@ Key internal conventions in the runtime composition: - Docs in `docs/` and the changelog are largely Korean (operations, sales/acquisition-readiness package); code, code comments, and AGENTS.md are English. Match the language of whatever you are editing. - API behavior is under a stability contract (`docs/api-spec.md`, `docs/api-stability-contract.md`); changes to `/exchange` semantics or the response envelope need corresponding doc and smoke-check updates. - Security posture is fail-closed everywhere (audits, KPI gates, OIDC checks). Prefer adding a regression test over relaxing a check. -- See `AGENTS.md`'s "Cross-session coordination" section for the cross-session agent-coordination convention and this repo's out-of-scope status on the central review sidecar/egress gap (`ContextualWisdomLab/.github#1759`). \ No newline at end of file +- See `AGENTS.md`'s "Cross-session coordination" section for the cross-session agent-coordination convention and this repo's out-of-scope status on the central review sidecar/egress gap (`ContextualWisdomLab/.github#1759`). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5c6287310..06643704b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,7 +39,8 @@ customer/operator facing. `hourly-product-development.yml` runs a proposal-only coding session through the same `contextual-orchestrator` gateway contract as review -(`NOEMA_LLM_API_URL`, `NOEMA_LLM_MODEL`, dedicated `NOEMA_LLM_API_KEY`) when +(`NOEMA_LLM_API_URL`, exact released `NOEMA_LLM_API_URL_ALLOWLIST_JSON`, +`NOEMA_LLM_MODEL`, dedicated `NOEMA_LLM_API_KEY`) when the PR queue is empty. It does not iterate a model-candidate list. It cannot review, merge, release, or deploy; the existing hourly commercial-readiness loop retains exact-head governance and SHA-bound merge authority. diff --git a/Dockerfile.patch-validator b/Dockerfile.patch-validator index caf1ab9e1..743eebae6 100644 --- a/Dockerfile.patch-validator +++ b/Dockerfile.patch-validator @@ -2,12 +2,13 @@ FROM alpine:3.24.1@sha256:79ff19e9084a00eece421b2523fb93e22d730e2c0e525905de047e848e56d95f AS node_builder -ARG NODE_VERSION=24.19.0 -ARG NODE_SOURCE_SHA256=f6d95e10a0431ee1067fc6aabe9f762908b4716dd35324e1ddb4b1466b76659f +ARG NODE_VERSION=24.21.0 +ARG NODE_SOURCE_SHA256=a6f54defb6fd7c84f41dba13d61e78e9b4e0961712cf61f29715c05f5ced94fc ARG OPENSSL_VERSION=3.5.8 ARG OPENSSL_SOURCE_SHA256=a8f84a39918ec6415ce765d9b429d313ba97b8143169c172e734b9514464f5b2 ARG CARES_VERSION=1.34.8 ARG CARES_SOURCE_SHA256=c222b6d681096f9444d2c4863d2c1174019e27cacca0a4a5c114d36dd7d7bf78 +ARG UNDICI_VERSION=7.29.1 RUN apk add --no-cache \ binutils-gold \ @@ -108,24 +109,26 @@ RUN ./configure \ && make -j"$(getconf _NPROCESSORS_ONLN)" V= \ && make install \ && test "$(/opt/node/bin/node --version)" = "v${NODE_VERSION}" \ - && test "$(/opt/node/bin/npm --version)" = "11.17.0" \ + && test "$(/opt/node/bin/npm --version)" = "11.19.0" \ && CARES_VERSION="${CARES_VERSION}" /opt/node/bin/node --input-type=module --eval='if (process.versions.ares !== process.env.CARES_VERSION) throw new Error(`unexpected c-ares ${process.versions.ares}`)' \ + && UNDICI_VERSION="${UNDICI_VERSION}" /opt/node/bin/node --input-type=module --eval='if (process.versions.undici !== process.env.UNDICI_VERSION) throw new Error(`unexpected Undici ${process.versions.undici}`)' \ && OPENSSL_VERSION="${OPENSSL_VERSION}" /opt/node/bin/node --input-type=module --eval='if (process.versions.openssl !== process.env.OPENSSL_VERSION) throw new Error(`unexpected OpenSSL ${process.versions.openssl}`)' \ && /opt/node/bin/node --input-type=module --eval='/\p{ID_Continue}/u.test("a")' \ && ! readelf -l /opt/node/bin/node | grep -q 'Requesting program interpreter' \ && ! readelf -d /opt/node/bin/node | grep -q '(NEEDED)' \ && strip /opt/node/bin/node \ && printf '%s\n' \ - '{"name":"node","version":"24.19.0","cpe":"cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*","license":"MIT","type":"binary","architecture":"x86_64","appCpe":"cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*","system":"nodejs","vendor":"nodejs","sourceRepo":"https://github.com/nodejs/node"}' \ + '{"name":"node","version":"24.21.0","cpe":"cpe:2.3:a:nodejs:node.js:24.21.0:*:*:*:*:*:*:*","license":"MIT","type":"binary","architecture":"x86_64","appCpe":"cpe:2.3:a:nodejs:node.js:24.21.0:*:*:*:*:*:*:*","system":"nodejs","vendor":"nodejs","sourceRepo":"https://github.com/nodejs/node"}' \ >/tmp/node-package-note.json \ && objcopy \ --add-section .note.package=/tmp/node-package-note.json \ --set-section-flags .note.package=noload,readonly \ /opt/node/bin/node \ && readelf -p .note.package /opt/node/bin/node \ - | grep -Fq 'cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*' \ + | grep -Fq 'cpe:2.3:a:nodejs:node.js:24.21.0:*:*:*:*:*:*:*' \ && test "$(/opt/node/bin/node --version)" = "v${NODE_VERSION}" \ && CARES_VERSION="${CARES_VERSION}" /opt/node/bin/node --input-type=module --eval='if (process.versions.ares !== process.env.CARES_VERSION) throw new Error(`unexpected c-ares ${process.versions.ares}`)' \ + && UNDICI_VERSION="${UNDICI_VERSION}" /opt/node/bin/node --input-type=module --eval='if (process.versions.undici !== process.env.UNDICI_VERSION) throw new Error(`unexpected Undici ${process.versions.undici}`)' \ && OPENSSL_VERSION="${OPENSSL_VERSION}" /opt/node/bin/node --input-type=module --eval='if (process.versions.openssl !== process.env.OPENSSL_VERSION) throw new Error(`unexpected OpenSSL ${process.versions.openssl}`)' \ && /opt/node/bin/node --input-type=module --eval='/\p{ID_Continue}/u.test("a")' \ && ! readelf -l /opt/node/bin/node | grep -q 'Requesting program interpreter' \ diff --git a/README.md b/README.md index 99030df59..0fa106f36 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ Host-facing gateway configuration: | Name | Meaning | | --- | --- | | `NOEMA_LLM_API_URL` | HTTPS OpenAI-compatible base ending in `/v1` | +| `NOEMA_LLM_API_URL_ALLOWLIST_JSON` | Non-empty JSON array of exact released contextual-orchestrator `/v1` endpoints; order has no routing meaning | | `NOEMA_LLM_MODEL` | Routing alias, canonically `orchestrator/free` (fail-closed zero-cost pool, ZDR-first) | | `NOEMA_LLM_API_KEY` | Dedicated gateway inference token | diff --git a/contracts/orchestrator-gateway.json b/contracts/orchestrator-gateway.json index 46252f64b..38d1d593a 100644 --- a/contracts/orchestrator-gateway.json +++ b/contracts/orchestrator-gateway.json @@ -1,6 +1,6 @@ { "id": "contextual-orchestrator-gateway", - "version": 1, + "version": 2, "service": "contextual-orchestrator", "routing_alias": "orchestrator/free", "api_url": { @@ -8,7 +8,11 @@ "pathname_suffix": "/v1", "allow_userinfo": false, "allow_query": false, - "allow_fragment": false + "allow_fragment": false, + "require_canonical_serialization": true, + "allow_loopback": false, + "exact_released_endpoint_membership": true, + "allowlist_order_has_routing_meaning": false }, "healthz": { "unauthenticated": true, @@ -20,6 +24,7 @@ }, "transport_names": { "api_url": "NOEMA_LLM_API_URL", + "api_url_allowlist": "NOEMA_LLM_API_URL_ALLOWLIST_JSON", "model": "NOEMA_LLM_MODEL", "api_key": "NOEMA_LLM_API_KEY" }, diff --git a/docs/OPERABILITY.md b/docs/OPERABILITY.md index b0228d43d..7ce9eaf30 100644 --- a/docs/OPERABILITY.md +++ b/docs/OPERABILITY.md @@ -57,6 +57,7 @@ GitHub automation category: - exact reviewer App bot login; - maintenance activation flag; - contextual-orchestrator gateway endpoint `NOEMA_LLM_API_URL`; +- released exact endpoint set `NOEMA_LLM_API_URL_ALLOWLIST_JSON` (non-secret; order has no routing meaning); - dedicated gateway inference token `NOEMA_LLM_API_KEY`; - routing alias `orchestrator/free`; - reviewer model gateway credential contract, kept separate from repository publication authority. @@ -126,7 +127,8 @@ The proposal flow must preserve three trust domains. - no repository write credential; - OpenCode uses only contextual-orchestrator's released gateway contract with routing alias `orchestrator/free`; -- receives `NOEMA_LLM_API_URL` and the dedicated `NOEMA_LLM_API_KEY`, never an upstream provider credential; +- receives `NOEMA_LLM_API_URL`, the released exact endpoint set `NOEMA_LLM_API_URL_ALLOWLIST_JSON`, and the dedicated `NOEMA_LLM_API_KEY`, never an upstream provider credential; +- refuses any selected URL outside that set before health probing or credential-bearing configuration; `/healthz` is liveness evidence, not identity authority; - does not define provider/model/group/paid fallback, retry, or model wall-clock timeout policy locally; - bounded file/diff output; - no symlink/gitlink authority; diff --git a/docs/TRD.md b/docs/TRD.md index 064f5898a..084c5186a 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -294,7 +294,7 @@ Atomic proposal-publication과 publisher-lease control은 protected main에 구 - GitHub Actions development/maintenance model work는 OpenCode Agent가 `contextual-orchestrator`의 released API/client/schema contract를 통해 수행합니다. - routing identity는 `orchestrator/free`이며 Noema가 provider/model/group/paid fallback을 선택하지 않습니다. -- gateway endpoint와 inference capability는 `NOEMA_LLM_API_URL`, 전용 gateway token은 `NOEMA_LLM_API_KEY`로 전달합니다. +- gateway endpoint와 inference capability는 `NOEMA_LLM_API_URL`, 릴리스된 exact endpoint 집합은 `NOEMA_LLM_API_URL_ALLOWLIST_JSON`, 전용 gateway token은 `NOEMA_LLM_API_KEY`로 전달합니다. 선택된 URL이 그 집합의 canonical exact member가 아니면 credential 또는 network I/O 전에 실패합니다. 집합 순서는 routing/fallback 의미가 없습니다. - upstream provider credentials(`NVIDIA_NIM_API_KEY`, `NVIDIA_NIM_API_KEY_SUB`, `BYTEZ_API_KEY`, `OPENROUTER_API_KEY`, `OPENAI_API_KEY`)은 Noema model jobs의 credential contract가 아니며 repository가 읽거나 fallback authority로 사용하지 않습니다. - Noema는 model wall-clock timeout, retry, provider failover를 별도로 소유하지 않습니다. 사용자 취소, provider 종료, 관리자 정책 timeout은 서로 다른 종료 원인으로 보존합니다. - `COPILOT_GITHUB_TOKEN`은 사용하지 않습니다. diff --git a/docs/contextual-orchestrator-reviewer-cutover.md b/docs/contextual-orchestrator-reviewer-cutover.md index 9f60cbc71..bd1e9df78 100644 --- a/docs/contextual-orchestrator-reviewer-cutover.md +++ b/docs/contextual-orchestrator-reviewer-cutover.md @@ -15,6 +15,9 @@ The reusable contract is `contracts/orchestrator-gateway.json` and ## Target contract - `NOEMA_LLM_API_URL` is an HTTPS OpenAI-compatible base URL ending in `/v1`. +- `NOEMA_LLM_API_URL_ALLOWLIST_JSON` is a non-empty JSON array of exact, + released HTTPS `/v1` endpoints. The selected URL must be a canonical member; + array order has no routing, retry, or fallback meaning. - `GET /healthz` returns `{"status":"ok","service":"contextual-orchestrator",...}`. - `NOEMA_LLM_MODEL` is the canonical routing alias @@ -25,11 +28,12 @@ The reusable contract is `contracts/orchestrator-gateway.json` and failover, allowlists, budgets, circuit breakers, and audit stay in the gateway. -Every Noema LLM workflow rejects known direct OpenAI, GitHub Models, -OpenRouter, NVIDIA NIM, and Bytez hosts even if they implement an -OpenAI-compatible API. Noema does not sequentially try the next model or -agent; routing is pinned to `orchestrator/free`, the fail-closed zero-cost -pool, ZDR-first. +Every Noema LLM workflow rejects endpoints outside the released exact set and +known direct OpenAI, GitHub Models, OpenRouter, NVIDIA NIM, and Bytez hosts even +if they implement an OpenAI-compatible API. A self-asserted `/healthz` +`service` value is liveness evidence after admission, never identity authority. +Noema does not sequentially try the next model or agent; routing is pinned to +`orchestrator/free`, the fail-closed zero-cost pool, ZDR-first. ## Approval-bound activation @@ -45,7 +49,9 @@ workflow logs, or this repository. `contextual-orchestrator`. Verify with the inference token that a bounded `/v1/chat/completions` request succeeds and an admin endpoint is denied. 4. After explicit approval, create the organization Actions secret - `NOEMA_LLM_API_KEY` and set `NOEMA_LLM_API_URL` and `NOEMA_LLM_MODEL`. + `NOEMA_LLM_API_KEY`; set `NOEMA_LLM_API_URL`, + `NOEMA_LLM_API_URL_ALLOWLIST_JSON`, and `NOEMA_LLM_MODEL`. Derive the + allowlist only from immutable contextual-orchestrator release evidence. 5. Dispatch a canary review against a draft pull request at an exact current head SHA. Confirm the Noema App review, gateway audit event, chosen upstream, and cost/budget record all refer to the same request. diff --git a/docs/development/contributor-and-agent-procedure.md b/docs/development/contributor-and-agent-procedure.md index b4df79948..01689ca61 100644 --- a/docs/development/contributor-and-agent-procedure.md +++ b/docs/development/contributor-and-agent-procedure.md @@ -61,7 +61,9 @@ Sandbox and evidence-collection isolation: `.github/workflows/hourly-product-development.yml` runs a proposal-only OpenCode session through the same `contextual-orchestrator` gateway contract as -review (`NOEMA_LLM_API_URL`, `NOEMA_LLM_MODEL`, dedicated `NOEMA_LLM_API_KEY`). +review (`NOEMA_LLM_API_URL`, exact released +`NOEMA_LLM_API_URL_ALLOWLIST_JSON`, `NOEMA_LLM_MODEL`, dedicated +`NOEMA_LLM_API_KEY`). Admission is work-conserving: existing open pull requests are not a global stop condition, but publication fails closed unless the proposal changed path set is disjoint from every open PR and the default-branch base is unchanged. It does diff --git a/docs/doctoring/codegraph-sqlite-warning-remediation.md b/docs/doctoring/codegraph-sqlite-warning-remediation.md index a40ad61e3..c0f88914e 100644 --- a/docs/doctoring/codegraph-sqlite-warning-remediation.md +++ b/docs/doctoring/codegraph-sqlite-warning-remediation.md @@ -8,7 +8,7 @@ Noema's direct CodeGraph sandbox launchers must not pass `--disable-warning=Expe CodeGraph v1.4.1 is published as a self-contained bundle. Its tagged `scripts/build-bundle.sh` defaults the embedded runtime to Node `v24.16.0`, and the tagged release workflow invokes that build script without overriding the Node version for each platform. This means the lock-pinned Linux package Noema mounts into its quarantine boundary carries Node v24.16.0 rather than inheriting an ambient host Node. -Node changed `node:sqlite` from experimental Stability 1.1 to release-candidate Stability 1.2 in v24.15.0. The exact Node v24.14.0 `lib/sqlite.js` still imports `emitExperimentalWarning` and calls `emitExperimentalWarning('SQLite')`; the exact Node v24.16.0 file contains only the internal SQLite binding export and no warning emitter. Noema's canonical hosted development runtime is newer again, Node v24.19.0, and the executable regression loads `node:sqlite` in that exact hosted runtime and rejects either `ExperimentalWarning` or the former `SQLite is an experimental feature` diagnostic. +Node changed `node:sqlite` from experimental Stability 1.1 to release-candidate Stability 1.2 in v24.15.0. The exact Node v24.14.0 `lib/sqlite.js` still imports `emitExperimentalWarning` and calls `emitExperimentalWarning('SQLite')`; the exact Node v24.16.0 file contains only the internal SQLite binding export and no warning emitter. Noema's canonical hosted development runtime is newer again, Node v24.21.0, and the executable regression loads `node:sqlite` in that exact hosted runtime and rejects either `ExperimentalWarning` or the former `SQLite is an experimental feature` diagnostic. Therefore a CodeGraph version bump is neither necessary nor sufficient for this warning repair. The warning source disappeared in the embedded Node line before the currently pinned CodeGraph 1.4.1 bundle was published. Upstream CodeGraph main still carries compatibility warning-suppression logic for other launch paths and older Node lines; that does not justify keeping a blanket suppression in Noema's reviewed direct launcher when Noema's pinned bundle identity already excludes the emitting runtime. @@ -25,7 +25,7 @@ If a future pinned CodeGraph bundle changes its embedded Node line and an `Exper - Node v24.14.0 `lib/sqlite.js`: emits the `SQLite` experimental warning. - Node v24.16.0 `lib/sqlite.js`: no experimental-warning emitter remains. - Node v24.16.0 SQLite documentation records the v24.15.0 promotion to Stability 1.2 (release candidate). -- Noema `.github/workflows/ci.yml` pins the canonical hosted Node toolchain to v24.19.0. +- Noema `.github/workflows/ci.yml` pins the canonical hosted Node toolchain to v24.21.0. ## References diff --git a/docs/doctoring/hourly-nim-opencode-development.md b/docs/doctoring/hourly-nim-opencode-development.md index 3eaa5b07f..8445c565b 100644 --- a/docs/doctoring/hourly-nim-opencode-development.md +++ b/docs/doctoring/hourly-nim-opencode-development.md @@ -88,7 +88,7 @@ GitHub provides no atomic “create a PR only if none exists” transaction. The ### Dedicated development and publication credentials -The workflow maps `secrets.NOEMA_LLM_API_KEY` and `vars.NOEMA_LLM_API_URL` through the same gateway contract as production review. It does not use GitHub Copilot, GitHub Models, NVIDIA NIM, Bytez, OpenRouter, or OpenAI provider keys. The reviewer App private key and `/exchange` OIDC broker remain unchanged. Sequential model-candidate failover is forbidden; the orchestrator selects min-cost / max-performance. +The workflow maps `secrets.NOEMA_LLM_API_KEY`, `vars.NOEMA_LLM_API_URL`, and immutable-release-derived `vars.NOEMA_LLM_API_URL_ALLOWLIST_JSON` through the same gateway contract as production review. The selected canonical HTTPS `/v1` URL must be an exact member before health I/O or token-bearing configuration; the non-empty array is an unordered admission set, never a routing/retry/fallback list. It does not use GitHub Copilot, GitHub Models, NVIDIA NIM, Bytez, OpenRouter, or OpenAI provider keys. The reviewer App private key and `/exchange` OIDC broker remain unchanged. Sequential model-candidate failover is forbidden; provider/model selection remains contextual-orchestrator authority. Publication reuses the repository's existing dedicated Maintainer App variables and private-key secret. It does not repurpose the reviewer App identity or key contract. The separation preserves independent review evidence and gives generated PRs a normal event path into `ci`, `reviewer-ci`, and Security Scan. diff --git a/docs/doctoring/hourly-product-development-prerequisites.md b/docs/doctoring/hourly-product-development-prerequisites.md index fccc7f834..25cf5fb83 100644 --- a/docs/doctoring/hourly-product-development-prerequisites.md +++ b/docs/doctoring/hourly-product-development-prerequisites.md @@ -8,7 +8,7 @@ This doctoring note uses APA 7 reference form. It separates source-supported fac The centrally dispatched development path has two independent credential prerequisites: -1. `NOEMA_LLM_API_URL` and `NOEMA_LLM_API_KEY` permit the read-only OpenCode proposal job to reach the `contextual-orchestrator` gateway. +1. `NOEMA_LLM_API_URL`, immutable-release-derived `NOEMA_LLM_API_URL_ALLOWLIST_JSON`, and `NOEMA_LLM_API_KEY` permit the read-only OpenCode proposal job to reach an exact released `contextual-orchestrator` gateway endpoint. 2. `NOEMA_MAINTAINER_APP_CLIENT_ID` and `NOEMA_MAINTAINER_APP_PRIVATE_KEY` permit the later non-executing publisher to create one repository-scoped branch and pull request. Checking only the inference token can spend model compute on a proposal that the workflow is structurally unable to publish. That is a deterministic configuration failure rather than a model-quality failure and should be rejected before checkout or inference. @@ -29,6 +29,7 @@ Before OpenCode starts, the proposal gate evaluates only presence booleans: - `NOEMA_LLM_API_KEY != ''` - `NOEMA_LLM_API_URL != ''` +- `NOEMA_LLM_API_URL_ALLOWLIST_JSON != ''` - `NOEMA_MAINTAINER_APP_CLIENT_ID != ''` - `NOEMA_MAINTAINER_APP_PRIVATE_KEY != ''` @@ -42,7 +43,7 @@ Manual `dry_run` deliberately bypasses credential-presence requirements because ## Gateway contract, not provider keys -The gate uses the same dedicated gateway names as production review: `NOEMA_LLM_API_URL`, `NOEMA_LLM_MODEL`, and `NOEMA_LLM_API_KEY`. It does not read `NVIDIA_NIM_API_KEY`, `BYTEZ_API_KEY`, `OPENROUTER_API_KEY`, or `OPENAI_API_KEY`. Development proposal authority, publication authority, and independent review App identity remain separate even though both LLM jobs share the orchestrator contract. +The gate uses the same dedicated gateway names as production review: `NOEMA_LLM_API_URL`, `NOEMA_LLM_API_URL_ALLOWLIST_JSON`, `NOEMA_LLM_MODEL`, and `NOEMA_LLM_API_KEY`. The allowlist is a non-empty unordered admission set of canonical released HTTPS `/v1` endpoints; its order has no routing, retry, or fallback meaning, and a self-asserted `/healthz` response cannot create endpoint authority. It does not read `NVIDIA_NIM_API_KEY`, `BYTEZ_API_KEY`, `OPENROUTER_API_KEY`, or `OPENAI_API_KEY`. Development proposal authority, publication authority, and independent review App identity remain separate even though both LLM jobs share the orchestrator contract. ## Verification contract @@ -50,7 +51,7 @@ Executable tests must prove that: - both Maintainer App presence booleans are evaluated in the pre-inference gate; - either missing value produces `dispatch=false` and `reason=maintainer_app_unavailable`; -- missing gateway URL or key produces `orchestrator_gateway_unavailable`; +- missing gateway URL, endpoint allowlist, or key produces `orchestrator_gateway_unavailable`; - unreadable open-PR inventory fails closed while the existence of a readable open PR does not globally suppress a healthy development pass; - a proposal whose exact path intersects any other open PR fails closed before remote creation; - after PR creation, path isolation is re-evaluated with the newly created PR excluded, so a raced overlapping PR causes cleanup rather than acceptance; diff --git a/docs/doctoring/package-manager-reproducibility.md b/docs/doctoring/package-manager-reproducibility.md index 15f54f175..5345f87af 100644 --- a/docs/doctoring/package-manager-reproducibility.md +++ b/docs/doctoring/package-manager-reproducibility.md @@ -2,7 +2,7 @@ ## 결정 -Noema의 검토·CI·lockfile 재생성 기준 개발 도구 체인은 **Node.js 24.19.0**과 **npm 11.17.0**으로 고정한다. `package.json`의 `packageManager`와 `devEngines`가 그 identity를 선언하고, CI는 설치 전에 실제 `node --version`과 `npm --version`을 exact-match로 다시 확인한다. 제품 자체의 지원 범위인 `engines.node >=22`와 개발·lockfile 생성 도구 체인은 별도 계약이다. +Noema의 검토·CI·lockfile 재생성 기준 개발 도구 체인은 **Node.js 24.21.0**과 **npm 11.19.0**으로 고정한다. `package.json`의 `packageManager`와 `devEngines`가 그 identity를 선언하고, CI는 설치 전에 실제 `node --version`과 `npm --version`을 exact-match로 다시 확인한다. 제품 자체의 지원 범위인 `engines.node >=22`와 개발·lockfile 생성 도구 체인은 별도 계약이다. CI의 JavaScript Action도 mutable major tag를 실행 근거로 사용하지 않는다. `actions/checkout` 6.0.2와 `actions/setup-node` 6.4.0의 검토된 full commit SHA를 workflow에 고정한다. 두 action 계열은 Node 24 runtime을 사용하는 현재 release line이다. 이 Action source pin 결정의 범위는 `.github/workflows/ci.yml`이다. @@ -16,7 +16,7 @@ CI의 JavaScript Action도 mutable major tag를 실행 근거로 사용하지 ## Frozen install과 predecessor 보존 -이 stack은 선행 보안 remediation이 고정한 `nanoid@3.3.17`을 그대로 보존한다. `npm ci --legacy-peer-deps=false --install-links=false`는 reviewed predecessor의 frozen-install 경계이며, 후속 재현성 작업이 이를 임의의 기본값으로 되돌리지 않는다. +이 stack은 선행 보안 remediation이 고정한 `nanoid@3.3.18`을 그대로 보존한다. `npm ci --legacy-peer-deps=false --install-links=false`는 reviewed predecessor의 frozen-install 경계이며, 후속 재현성 작업이 이를 임의의 기본값으로 되돌리지 않는다. npm 문서가 설명하듯 `npm ci`는 project manifest와 lockfile이 불일치하면 lockfile을 고쳐 주는 복구 경로가 아니라 실패하는 clean-install 경로다. 따라서 CI가 lockfile을 생성·수정하거나 PR branch를 자동 수선하지 않는다. @@ -72,7 +72,7 @@ Predecessor head의 green run, model comment, status-only signal, stale base, sy ## 재생성·검증 절차 -1. Node.js 24.19.0 / npm 11.17.0 exact identity를 확인한다. +1. Node.js 24.21.0 / npm 11.19.0 exact identity를 확인한다. 2. 현재 protected/base lockfile을 immutable 비교 기준으로 보존한다. 3. 목표 dependency만 변경하고 unrelated metadata churn을 별도 근거 없이 수용하지 않는다. 4. lockfile diff가 존재하면 schemaVersion 3 policy를 exact base, exact package before/after digest, exact `topLevelMetadataDigests`, 그리고 필요 시 `bulkChange` evidence에 결합한다. diff --git a/docs/doctoring/patch-validator-image.md b/docs/doctoring/patch-validator-image.md index 9cc23b811..c5fea33cd 100644 --- a/docs/doctoring/patch-validator-image.md +++ b/docs/doctoring/patch-validator-image.md @@ -18,7 +18,7 @@ PR #65 establishes a host-side boundary that authenticates an exact Git source, The current goal is narrower than production publication: prove that one exact pull-request head can build and execute a credential-free validator image under a least-privileged, fail-closed verification process without conflating image execution with trusted evidence, independent approval, protected merge, provenance, release, or deployment authority. -On 2026-08-07 the runtime design changed from a distribution runtime to a fully static Node.js 24.19.0 executable copied into a `scratch` final image. That change removed the previously observed Debian runtime CVEs and distribution attack surface, but it also removed package-manager metadata. Two related threats therefore had to be addressed: a package-oriented scanner could report a clean image while never classifying the self-compiled Node executable, and a Node-level classification could still hide bundled dependency versions inside the static binary. The current design uses independent binary presence evidence plus an exact-image `process.versions` inventory and per-component vulnerability evidence. +On 2026-08-07 the runtime design changed from a distribution runtime to a fully static Node.js 24.21.0 executable copied into a `scratch` final image. That change removed the previously observed Debian runtime CVEs and distribution attack surface, but it also removed package-manager metadata. Two related threats therefore had to be addressed: a package-oriented scanner could report a clean image while never classifying the self-compiled Node executable, and a Node-level classification could still hide bundled dependency versions inside the static binary. The current design uses independent binary presence evidence plus an exact-image `process.versions` inventory and per-component vulnerability evidence. ## Evidence classification @@ -29,7 +29,7 @@ The pull-request workflow and tests measure or enforce: - exact pull-request head checkout and live-head equality before and after verification; - a clean worktree and immutable Dockerfile frontend; - a digest-pinned Node builder; -- SHA-256 verification of the official Node.js 24.19.0 source archive; +- SHA-256 verification of the official Node.js 24.21.0 source archive; - a fully static Node build copied into a `scratch` final image; - rejection of dynamic interpreters, dynamic `NEEDED` dependencies, shared libraries, native addons, shells, and package managers in the final runtime; - numeric non-root runtime identity and a fixed exec-form entrypoint; @@ -41,7 +41,7 @@ The pull-request workflow and tests measure or enforce: - Trivy CycloneDX and vulnerability receipts for package and JavaScript dependency coverage; - checksum-manifest-pinned Syft 1.50.0 inventory of the same exact local image; - checksum-manifest-pinned Grype 0.116.1 vulnerability scanning of the same exact local image; -- fail-closed verification that Syft identifies exactly one Node 24.19.0 executable at `/nodejs/bin/node` with the expected Node.js CPE; +- fail-closed verification that Syft identifies exactly one Node 24.21.0 executable at `/nodejs/bin/node` with the expected Node.js CPE; - exact-image `process.versions` capture with exact component-set equality against the reviewed inventory; - `modules` and `napi` handled only as reviewed ABI/runtime metadata and never fabricated as vulnerability packages; - every other `process.versions` key classified as a bundled dependency with a reviewed PURL or CPE; @@ -61,7 +61,7 @@ The external sources support these general requirements: - SBOM and vulnerability evidence must identify the artifact actually consumed; an empty finding set is not useful if the relevant component was never inventoried. - Trivy's OS-package vulnerability scanner does not support third-party or self-compiled packages/binaries. Therefore Trivy alone cannot serve as positive evidence that the self-compiled Node runtime was assessed. - Syft's binary classifier catalog includes Node executable classification and Node.js CPE evidence, which supports an explicit runtime-presence assertion. -- Node.js 24.19.0 documents `process.versions` as an object of version strings for Node.js and its dependencies. This supports using the exact built runtime as the source of reviewed embedded dependency-version declarations, while not claiming it is a complete binary-composition proof. +- Node.js 24.21.0 documents `process.versions` as an object of version strings for Node.js and its dependencies. This supports using the exact built runtime as the source of reviewed embedded dependency-version declarations, while not claiming it is a complete binary-composition proof. - Grype supports container/SBOM inputs and package identities including PURLs and CPEs; matcher selection depends on package type and can fall back to CPE/NVD matching for otherwise modeled packages. Its output remains vulnerability-database- and identity-quality-dependent. - Build provenance, artifact signatures, independent approval, branch protection, release acceptance, and deployment authority are separate evidence/authority planes. @@ -82,7 +82,7 @@ Noema keeps Trivy for its existing package/language coverage and adds an indepen 3. authenticate each Linux/amd64 archive through the already authenticated manifest before extraction; 4. run Syft against `docker:` and retain native Syft JSON; 5. require Syft's source image ID to equal the trusted Docker image ID; -6. require exactly one `node` package at version 24.19.0 located at `/nodejs/bin/node` and carrying a Node.js 24.19.0 CPE; +6. require exactly one `node` package at version 24.21.0 located at `/nodejs/bin/node` and carrying a Node.js 24.21.0 CPE; 7. run Grype independently against the same exact local Docker image with `--config /dev/null`, preventing a repository-local `.grype.yaml` from silently introducing ignore policy; 8. require Grype's source image ID to equal the trusted Docker image ID; 9. reject any non-empty `ignoredMatches` collection; @@ -104,7 +104,7 @@ Node.js exposes the runtime's own dependency version declarations through `proce The exact-image embedded-runtime lane now: 1. executes the exact built `/nodejs/bin/node` and bounds the serialized `process.versions` record; -2. requires `process.versions.node` to equal the reviewed Node.js version `24.19.0`; +2. requires `process.versions.node` to equal the reviewed Node.js version `24.21.0` and `process.versions.undici` to equal fixed bundled Undici `7.29.1`; 3. requires the reviewed inventory component keys to equal every non-`node` `process.versions` key exactly; 4. treats only `modules` and `napi` as runtime metadata, with exact reviewed meanings, and forbids package identities for those counters; 5. requires every other key to be a `bundled_dependency` with an explicit reviewed PURL or CPE; @@ -280,7 +280,7 @@ Before describing an exact head as verified, retain or link: - exact immutable workflow source; - local image metadata and content ID; - Trivy CycloneDX and vulnerability receipts; -- Syft native binary inventory proving Node 24.19.0 presence; +- Syft native binary inventory proving Node 24.21.0 presence; - Grype exact-image vulnerability receipt with no ignored/blocking findings; - bounded exact-image `process.versions` record; - reviewed embedded-runtime inventory with exact component-set equality; @@ -316,7 +316,7 @@ National Institute of Standards and Technology. (2017). *Application container s National Institute of Standards and Technology. (2022). *Secure software development framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). U.S. Department of Commerce. https://doi.org/10.6028/NIST.SP.800-218 -Node.js. (2026). *Process: `process.versions` (Node.js v24.19.0 documentation)*. https://nodejs.org/download/release/v24.19.0/docs/api/process.html#processversions +Node.js. (2026). *Process: `process.versions` (Node.js v24.21.0 documentation)*. https://nodejs.org/download/release/v24.21.0/docs/api/process.html#processversions Open Container Initiative. (2024). *OCI image format specification* (Version 1.1.1). https://specs.opencontainers.org/image-spec/?v=v1.1.1 diff --git a/docs/doctoring/patch-validator-node-24-21-undici-2026-09-30.md b/docs/doctoring/patch-validator-node-24-21-undici-2026-09-30.md new file mode 100644 index 000000000..024e5a310 --- /dev/null +++ b/docs/doctoring/patch-validator-node-24-21-undici-2026-09-30.md @@ -0,0 +1,69 @@ +# Patch-validator Node 24.21.0 / bundled Undici 7.29.1 수리 + +## 상태와 범위 + +**Status: Proposed owner repair.** 이 문서는 Noema issue #734의 canonical runtime 수리를 기록한다. Local RED→GREEN과 source identity 검증은 완료했지만, exact PR-head image build·smoke·SBOM·provenance·vulnerability receipt, fresh review, ordinary merge와 immutable release가 끝나기 전에는 protected 또는 released completion이 아니다. + +## 문제와 사용자·운영 장면 + +Dependent Draft PR #733의 exact head `cab54e5d6c27768f67361b824feebfb210493397`에서 patch-validator run `36681968884`는 build, smoke와 SBOM 생성까지 완료한 뒤 vulnerability receipt verification에서 실패했다. Artifact `11088167853`(SHA-256 `72b8806b65cdd7607ebcd4eff6b33f58784ab2150761c209d93ab13c73b0c3f0`)은 protected runtime Node 24.19.0이 bundled Undici 7.29.0을 포함하고 GHSA-3wwx-pv8p-q78v에 해당함을 보였다. 따라서 동일 head rerun, scanner ignore, severity downgrade 또는 consumer workaround는 결과를 바꾸는 수리가 아니다. + +구매자·운영자에게 필요한 실패 장면은 명확하다. Patch-validator가 untrusted pull-request patch를 검증하더라도 자체 static runtime에 알려진 취약점이 남아 있으면 그 검증 결과를 release evidence로 승격할 수 없다. 반대로 top-level Node CPE가 clean하다는 이유만으로 embedded Undici scan을 생략하면 false negative가 된다. + +## Root cause + +Noema의 Dockerfile, hosted workflow, package-manager identity, package note/CPE, exact `process.versions` inventory, applicability gate, tests와 문서가 Node 24.19.0/npm 11.17.0을 하나의 reviewed identity로 고정했다. 그 Node release는 Undici 7.29.0을 bundle한다. Inventory catalog는 Undici를 별도 PURL로 스캔했지만 fixed minimum을 코드로 강제하지 않아, 취약 runtime을 선택한 순간 raw scanner receipt가 올바르게 owner defect를 차단했다. + +## 선택과 기각된 대안 + +선택한 수리는 official Node.js v24.21.0 source를 exact SHA-256으로 인증해 기존 fully-static `scratch` image를 다시 빌드하는 것이다. Signed tag `v24.21.0`은 verified annotated tag object `f37d7da830134b5314a14ced553273786a2bb4cd`에서 commit `955266bfdd854cd280dffd47548673914484e4c0`을 가리킨다. Official source tarball SHA-256은 `a6f54defb6fd7c84f41dba13d61e78e9b4e0961712cf61f29715c05f5ced94fc`이고, exact source는 npm 11.19.0과 Undici 7.29.1을 선언한다. + +다음 대안은 기각했다. + +- scanner ignore/VEX/severity 완화: 실제 bundled component를 제거하지 않는다. +- npm application override만 변경: `process.versions.undici`의 Node-bundled source를 바꾸지 않는다. +- mutable branch/head archive: TOCTOU와 source-identity 혼동을 만든다. +- c-ares/OpenSSL overlay 제거: 독립적으로 검토된 기존 보안 수리를 되돌린다. +- dependent #733에서 image를 우회: canonical Noema owner defect를 consumer에 복제한다. + +## Executable RED→GREEN + +RED는 `generateEmbeddedRuntimeInventory({ node: "24.21.0", undici: "7.29.0" }, exactDigest)`가 fixed Undici floor를 거부해야 한다는 회귀다. Production guard 전에는 Node mismatch만 검사하거나 vulnerable Undici를 inventory/scan plan에 허용해 이 oracle이 실패했다. GREEN은 다음 exact gates로 구성한다. + +1. inventory가 Node `24.21.0`과 Undici `7.29.1`을 각각 exact-match한다; +2. Undici는 `pkg:npm/undici@7.29.1` scan plan에 남아 raw per-component Grype receipt를 계속 요구한다; +3. 최종 static-runtime receipt verifier도 internally consistent한 다른 Undici version을 허용하지 않고 `process.versions.undici === "7.29.1"`을 독립적으로 강제한다; +4. Docker build는 strip/package-note 전후 모두 실제 executable의 `process.versions.undici`를 검사한다; +5. Node source SHA, package note/CPE, npm identity, workflow toolchain과 static-runtime applicability versions를 같은 delta에서 갱신한다; +6. full scanner receipt와 blocking severity policy를 그대로 보존한다. + +Local focused verification은 exact Node 24.21.0/npm 11.19.0에서 48 files / 339 tests GREEN이었다. Full typecheck도 GREEN이고, full suite는 763 files / 5,162 tests 가운데 762 files / 5,161 tests가 통과했다. 남은 한 fixture는 executor가 Unix-domain socket 생성을 `listen EPERM`으로 거부해 Noema code에 도달하기 전에 실패했다. 그 unsupported fixture만 제외하면 762 files / 5,161 tests가 모두 통과하지만, 해당 fixture가 unrelated dependency-license special-output branch 한 줄의 유일한 coverage source이므로 aggregate coverage는 99.98%다. Hosted exact-head CI가 socket fixture와 100% configured coverage를 다시 검증해야 한다. 이 결과는 local source evidence일 뿐 exact PR-head image receipt 또는 merge authority가 아니다. + +Final-verifier oracle repair의 별도 RED는 inventory, component, PURL, raw scan이 모두 Undici `7.29.0`으로 내부 일치하는 증거가 verifier를 통과한다는 것이었다. Shared reviewed runtime identity를 재사용하는 exact assertion 뒤 verifier 관련 9 files / 147 tests와 patch-validator 32 files / 277 tests, typecheck가 GREEN이다. Local full suite는 763 files 중 758 files, 5,155 tests가 통과했고 1 test가 skip됐다. 남은 5 files / 7 failures는 이 executor의 child-process·Unix-socket·symlink 제약에서 발생했으며 patch-validator delta 파일은 포함하지 않는다. Fresh exact-head hosted workflow가 configured 100% coverage와 image/SBOM/provenance/scanner evidence를 다시 증명해야 한다. + +## Exact embedded-runtime revalidation + +Exact Node 24.21.0 executable은 `process.versions`에서 다음 transition을 보고한다: nghttp2 `1.70.0`, SQLite `3.53.4`, V8 `13.6.233.17-node.53`, zlib `1.3.2.1-motley-8002e91`, Undici `7.29.1`, npm `11.19.0`. Existing c-ares `1.34.8`와 OpenSSL `3.5.8` overlay는 유지된다. + +V8 applicability는 새 signed tag source에 다시 결합했다. Exact `deps/v8/src/compiler/js-call-reducer.cc` blob `0577f9cafeeee721c157288c88b9404abe60364a`에는 `ReduceArraySort`가 없고, exact `deps/v8/src/maglev/maglev-graph-builder.cc` blob `db081bfde623c3fd491d4d773a560ac446b32660`에는 `TryReduceArrayPrototypeSort`가 없다. 따라서 기존 CVE-2026-85046 decision은 새 exact Node/V8/CPE/scanner tuple에서만 유지하며 어떤 mismatch도 fail closed한다. Exact PR-head raw scanner result가 새로운 finding을 보고하면 이 source inspection이 그것을 자동 면제하지 않는다. + +## 남은 완료 증거 + +- full typecheck/test/100% configured coverage/security audit; +- exact PR-head patch-validator image build, no-network/read-only/non-root smoke; +- exact image ID에 결합된 CycloneDX, Syft, Node CPE, embedded inventory, Grype/Trivy, provenance receipt; +- GHSA-3wwx-pv8p-q78v가 absent임을 보이는 raw scanner evidence; +- fresh exact-head review, applicable required checks와 qualifying independent approval; +- ordinary merge, immutable Noema release/evidence, 이후 dependent #733 refresh. + +## 참고문헌 — APA 7 + +GitHub Advisory Database. (2026). *GHSA-3wwx-pv8p-q78v*. GitHub. https://github.com/advisories/GHSA-3wwx-pv8p-q78v + +Node.js. (2026, September 8). *Node.js v24.21.0 (LTS)*. https://nodejs.org/en/blog/release/v24.21.0 + +Node.js. (2026, September 8). *Node.js v24.21.0 release archive*. https://nodejs.org/en/download/archive/v24.21.0 + +Node.js. (2026, September 8). *Node.js v24.21.0 source* (signed tag; commit `955266bfdd854cd280dffd47548673914484e4c0`). GitHub. https://github.com/nodejs/node/releases/tag/v24.21.0 + +OWASP Foundation. (2025). *Software component verification standard*. https://scvs.owasp.org/ diff --git a/docs/operations/hourly-product-development-prerequisites.md b/docs/operations/hourly-product-development-prerequisites.md index 3330e07e3..0b8c15344 100644 --- a/docs/operations/hourly-product-development-prerequisites.md +++ b/docs/operations/hourly-product-development-prerequisites.md @@ -9,6 +9,7 @@ 일반 실행에는 다음 값이 모두 필요합니다. - `NOEMA_LLM_API_URL`: `/v1`로 끝나는 HTTPS `contextual-orchestrator` 주소 +- `NOEMA_LLM_API_URL_ALLOWLIST_JSON`: immutable release evidence에서 파생한 canonical exact `/v1` endpoint의 non-empty JSON array. 선택 URL은 exact member여야 하며 배열 순서는 routing/retry/fallback 의미가 없음 - `NOEMA_LLM_API_KEY`: 전용 게이트웨이 추론 토큰. 상위 공급자 키가 아님 - 모델 라우팅은 workflow source가 `orchestrator/free`로 고정하며 별도 `NOEMA_LLM_MODEL` Actions variable을 요구하지 않음 - `NOEMA_MAINTAINER_APP_CLIENT_ID`: `ContextualWisdomLab/noema`에만 설치된 Maintainer GitHub App의 repository variable @@ -25,7 +26,7 @@ dispatch=false reason=maintainer_app_unavailable ``` -`NOEMA_LLM_API_KEY` 또는 `NOEMA_LLM_API_URL`이 없으면 `orchestrator_gateway_unavailable`로 종료합니다. pull request inventory를 읽지 못하거나 열린 PR이 있으면 각각 `pull_request_inventory_unavailable`, `open_pull_request`로 종료합니다. +`NOEMA_LLM_API_KEY`, `NOEMA_LLM_API_URL`, 또는 `NOEMA_LLM_API_URL_ALLOWLIST_JSON`이 없으면 `orchestrator_gateway_unavailable`로 종료합니다. 선택 URL이 exact member가 아니면 gateway preflight 단계가 health 요청과 credential-bearing 설정 전에 실패합니다. pull request inventory를 읽지 못하거나 열린 PR이 있으면 각각 `pull_request_inventory_unavailable`, `open_pull_request`로 종료합니다. ## dry_run @@ -36,11 +37,11 @@ reason=maintainer_app_unavailable 1. Maintainer App이 `ContextualWisdomLab/noema`에만 설치되어 있는지 확인합니다. 2. App 권한을 Metadata read, Contents write, Pull requests write로 제한합니다. 3. `NOEMA_MAINTAINER_APP_CLIENT_ID`와 `NOEMA_MAINTAINER_APP_PRIVATE_KEY`를 설정합니다. -4. 리뷰와 동일한 `NOEMA_LLM_API_URL`, `NOEMA_LLM_API_KEY`를 설정하고 모델은 source-pinned `orchestrator/free`인지 확인합니다. +4. 리뷰와 동일한 `NOEMA_LLM_API_URL`, `NOEMA_LLM_API_URL_ALLOWLIST_JSON`, `NOEMA_LLM_API_KEY`를 설정하고 모델은 source-pinned `orchestrator/free`인지 확인합니다. 5. `dry_run=true`로 prompt와 queue 판단을 검토합니다. 6. 임시 검증 PR에서 publication job이 짧은 수명의 repository-scoped token을 생성하고 정확히 한 branch와 한 PR만 만드는지 확인합니다. 7. 리뷰어 App 신원이나 `/exchange` OIDC 경계가 변경되지 않았는지 확인합니다. ## 운영 복구 -`maintainer_app_unavailable`이 나타나면 모델이나 timeout을 조정하지 않습니다. App 설치 범위, client ID variable, private-key secret과 key rotation 상태를 복구한 뒤 다시 실행합니다. `orchestrator_gateway_unavailable`이면 게이트웨이 URL, 전용 추론 토큰, `/healthz` 신원을 복구합니다. 의도적인 중지는 workflow를 비활성화하거나 App credential 또는 게이트웨이 토큰을 회수하여 수행합니다. +`maintainer_app_unavailable`이 나타나면 모델이나 timeout을 조정하지 않습니다. App 설치 범위, client ID variable, private-key secret과 key rotation 상태를 복구한 뒤 다시 실행합니다. `orchestrator_gateway_unavailable`이면 게이트웨이 URL, `NOEMA_LLM_API_URL_ALLOWLIST_JSON`, 전용 추론 토큰, `/healthz` 신원을 복구합니다. 의도적인 중지는 workflow를 비활성화하거나 App credential 또는 게이트웨이 토큰을 회수하여 수행합니다. diff --git a/docs/operations/hourly-product-development.md b/docs/operations/hourly-product-development.md index c24c5e37d..de06665e4 100644 --- a/docs/operations/hourly-product-development.md +++ b/docs/operations/hourly-product-development.md @@ -10,11 +10,11 @@ OpenCode 1.17.13은 코딩 에이전트로만 남고, 모델 호출은 리뷰와 ## 게이트웨이 계약과 실행 종료 권한 -공식 OpenCode 아카이브는 고정 버전과 SHA-256으로 검증합니다. 공급자는 `contextual-orchestrator` 한 곳만 허용합니다. `NOEMA_LLM_API_URL`은 `/v1`로 끝나는 HTTPS OpenAI 호환 주소여야 하고, `NOEMA_LLM_MODEL`은 canonical 라우팅 별칭 `orchestrator/free`로 소스에 고정하며, `NOEMA_LLM_API_KEY`는 전용 게이트웨이 추론 토큰입니다. 상위 공급자 키(`NVIDIA_NIM_API_KEY`, `NVIDIA_NIM_API_KEY_SUB`, `BYTEZ_API_KEY`, `OPENROUTER_API_KEY`, `OPENAI_API_KEY`)는 오케스트레이터 KV에만 두고 Noema 런타임에 넣지 않습니다. +공식 OpenCode 아카이브는 고정 버전과 SHA-256으로 검증합니다. 공급자는 `contextual-orchestrator` 한 곳만 허용합니다. `NOEMA_LLM_API_URL`은 `/v1`로 끝나는 HTTPS OpenAI 호환 주소이면서 `NOEMA_LLM_API_URL_ALLOWLIST_JSON`의 릴리스된 canonical exact member여야 하고, `NOEMA_LLM_MODEL`은 canonical 라우팅 별칭 `orchestrator/free`로 소스에 고정하며, `NOEMA_LLM_API_KEY`는 전용 게이트웨이 추론 토큰입니다. allowlist 순서는 routing/retry/fallback 의미가 없습니다. 상위 공급자 키(`NVIDIA_NIM_API_KEY`, `NVIDIA_NIM_API_KEY_SUB`, `BYTEZ_API_KEY`, `OPENROUTER_API_KEY`, `OPENAI_API_KEY`)는 오케스트레이터 KV에만 두고 Noema 런타임에 넣지 않습니다. Noema는 모델 후보를 순서대로 시도하지 않습니다. 공급자·모델 발견, 선택, 재시도와 폴백은 오케스트레이터의 책임입니다. 직접 NVIDIA NIM, OpenAI, GitHub Models, OpenRouter, Bytez 호스트로 폴백하지 않습니다. OpenCode 세션에는 Noema가 만든 추론·reasoning·stream·tool-call 경과시간 cutoff를 두지 않습니다. GNU `timeout`으로 세션을 종료하던 경로와 강제 종료 유예 설정은 제거했고, `propose_product_increment`에도 저장소가 작성한 `timeout-minutes`를 두지 않습니다. 정상 provider 종료, 사용자 취소, 오케스트레이터가 반환한 종료와 외부 관리자가 강제한 실행 종료를 같은 모델 실패로 해석하거나 다음 모델 선택의 근거로 사용하지 않습니다. 세션이 자체 오류로 끝나더라도 Noema에서 다음 모델을 고르지 않습니다. -공유 스크립트 `scripts/verify-orchestrator-gateway.mjs`가 리뷰와 동일한 사전 점검을 수행합니다. 인증 없이 `/healthz`가 `service=contextual-orchestrator`를 반환해야 하며, 알려진 직접 공급자 호스트는 거부합니다. 같은 계약은 `contracts/orchestrator-gateway.json`으로 공개되며 `ContextualWisdomLab/naruon`의 판단·결정 에이전트도 1급 소비자입니다. naruon 배선은 이 저장소가 아니라 별도 PR에서 합니다. +공유 스크립트 `scripts/verify-orchestrator-gateway.mjs`가 리뷰와 동일한 사전 점검을 수행합니다. exact allowlist membership은 네트워크와 credential-bearing config보다 먼저 확인하고, 그 뒤 인증 없이 `/healthz`가 `service=contextual-orchestrator`를 반환해야 합니다. `/healthz`의 자기 선언은 liveness 증거이며 identity authority가 아닙니다. 알려진 직접 공급자 호스트도 거부합니다. 같은 계약은 `contracts/orchestrator-gateway.json`으로 공개되며 `ContextualWisdomLab/naruon`의 판단·결정 에이전트도 1급 소비자입니다. naruon 배선은 이 저장소가 아니라 별도 PR에서 합니다. ## 세 runner의 자격 증명 분리 diff --git a/docs/orchestrator-gateway-consumer-contract.md b/docs/orchestrator-gateway-consumer-contract.md index 680428f29..c1990bbe7 100644 --- a/docs/orchestrator-gateway-consumer-contract.md +++ b/docs/orchestrator-gateway-consumer-contract.md @@ -16,22 +16,31 @@ node scripts/verify-orchestrator-gateway.mjs --print-contract ``` Reusable validation lives in `scripts/lib/orchestrator-gateway.mjs` -(`parseOrchestratorGatewayUrl`, `resolveOrchestratorModel`, +(`parseOrchestratorGatewayUrl`, `parseOrchestratorGatewayApiUrlAllowlist`, +`requireAllowedOrchestratorGatewayUrl`, `resolveOrchestratorModel`, `requireOrchestratorApiKey`, `verifyOrchestratorHealthz`, -`orchestratorGatewayConsumerContract`). The OpenCode config writer in the -same module is Noema-only. Do not clone an OpenCode sidecar into naruon. +`orchestratorGatewayConsumerContract`). Admission must parse the allowlist and +call `requireAllowedOrchestratorGatewayUrl`; `parseOrchestratorGatewayUrl` +alone is not sufficient. The OpenCode config writer in the same module is +Noema-only. Do not clone an OpenCode sidecar into naruon. ## Required settings | Name | Meaning | | --- | --- | -| `NOEMA_LLM_API_URL` | HTTPS OpenAI-compatible base ending in `/v1`. No userinfo, query, or fragment. | +| `NOEMA_LLM_API_URL` | Canonically serialized, non-loopback HTTPS OpenAI-compatible base ending in `/v1`. No userinfo, query, or fragment. | +| `NOEMA_LLM_API_URL_ALLOWLIST_JSON` | Non-empty JSON array of exact released contextual-orchestrator `/v1` endpoints. The selected URL must be an exact member; list order has no routing meaning. | | `NOEMA_LLM_MODEL` | One routing alias. Canonical value is `orchestrator/free` (fail-closed zero-cost pool, ZDR-first). | | `NOEMA_LLM_API_KEY` | Dedicated gateway inference token. Never an upstream provider key. | `GET /healthz` is unauthenticated, must respond with `Content-Type: application/json`, and must return `{"status":"ok","service":"contextual-orchestrator"}`. +This is bounded liveness and response-shape evidence, not endpoint identity. +Only exact membership in `NOEMA_LLM_API_URL_ALLOWLIST_JSON` authorizes an +endpoint to receive the dedicated gateway token. Operators derive that list +from an immutable contextual-orchestrator release contract or asset and record +its tag and digest; Noema does not discover endpoints or inspect CO source. At request time, secrets come from a KV / credential registry (the Worker `Env` binding in Noema; naruon must use its own KV-equivalent). Process diff --git a/docs/patch-validator-image.md b/docs/patch-validator-image.md index 8d5655ba4..61066e8d5 100644 --- a/docs/patch-validator-image.md +++ b/docs/patch-validator-image.md @@ -10,7 +10,7 @@ The current slice verifies a locally built Linux/amd64 image from the exact pull - checks and re-checks the live exact PR head with read-only GitHub authority; - builds with an immutable Dockerfile frontend and digest-pinned builders; -- compiles Node.js 24.19.0 from the official source tarball after fixed SHA-256 authentication; +- compiles Node.js 24.21.0 from the official source tarball after fixed SHA-256 authentication; - links Node fully statically and copies it into a `scratch` final image; - retains the exact Node.js source-distribution `LICENSE` inside the final image, bounds and verifies that notice from the exported runtime, and preserves it with the verification evidence; - excludes a shell, package manager, native addon, shared library, dynamic interpreter, and dynamic `NEEDED` dependency from the final runtime; @@ -75,7 +75,7 @@ The following authorities remain separate: source authentication, image build, u ## Image identity and runtime contents -`Dockerfile.patch-validator` pins its Dockerfile frontend and non-final builders by SHA-256 digest. The Node builder downloads the official Node.js 24.19.0 source tarball with a fixed SHA-256 and compiles it with the fully-static configuration. The final stage is `scratch`; it does not inherit a distribution runtime or package database. +`Dockerfile.patch-validator` pins its Dockerfile frontend and non-final builders by SHA-256 digest. The Node builder downloads the official Node.js 24.21.0 source tarball with a fixed SHA-256 and compiles it with the fully-static configuration. The final stage is `scratch`; it does not inherit a distribution runtime or package database. The static Node package note carries the reviewed Node.js application CPE and metadata. It deliberately does not claim a `pkg:generic` identity. Generic package URLs are not accepted as evidence that the configured vulnerability matcher can identify a component. @@ -107,13 +107,13 @@ Container stdout, stderr, and private result contents are not trusted as identit ## Static-runtime vulnerability boundary -Trivy covers the ordinary image/package and JavaScript dependency surface. Syft separately proves that the self-compiled executable is catalogued as Node 24.19.0 at `/nodejs/bin/node` with the reviewed Node.js CPE. Grype separately scans that exact local image. The trusted verifier binds scanner descriptors, source targets, image identity, match structure, ignored-match policy, and severity policy. +Trivy covers the ordinary image/package and JavaScript dependency surface. Syft separately proves that the self-compiled executable is catalogued as Node 24.21.0 at `/nodejs/bin/node` with the reviewed Node.js CPE. Grype separately scans that exact local image. The trusted verifier binds scanner descriptors, source targets, image identity, match structure, ignored-match policy, and severity policy. A zero match count is not independently interpreted as proof that every static dependency was evaluated. The embedded-runtime boundary below exists specifically to avoid that inference. ## Embedded-runtime component evidence -The workflow runs the exact built Node executable and records `process.versions`. The trusted verifier requires `node` to equal `24.19.0`. It accepts `modules` and `napi` only as reviewed runtime metadata with explicit reasons; every other key must be represented by the reviewed component-identity catalog. +The workflow runs the exact built Node executable and records `process.versions`. The trusted verifier requires `node` to equal `24.21.0` and `undici` to equal the reviewed fixed version `7.29.1`. The Docker build checks that Undici identity both before and after binary stripping/package-note insertion. It accepts `modules` and `napi` only as reviewed runtime metadata with explicit reasons; every other key must be represented by the reviewed component-identity catalog. For each reviewed embedded dependency, the catalog binds the exact `process.versions` key, inventory name, version, and either a supported npm PURL package identity or a reviewed application CPE vendor/product identity. Wildcards, placeholders, partial identities, arbitrary aliases, version substitution, package substitution, and unknown components fail closed. @@ -166,4 +166,4 @@ Failure handling is: identify the exact head and exact workflow run; separate bu ## Scientific and standards rationale -The implementation rationale and APA 7th references are maintained in the doctoring documents associated with this image and its embedded-runtime assessment. Public operations text intentionally describes only controls that current repository code and retained evidence can prove. Publication, signature, provenance, independent approval, protected merge, release acceptance, and deployment remain separate gates. \ No newline at end of file +The implementation rationale and APA 7th references are maintained in the doctoring documents associated with this image and its embedded-runtime assessment. Public operations text intentionally describes only controls that current repository code and retained evidence can prove. Publication, signature, provenance, independent approval, protected merge, release acceptance, and deployment remain separate gates. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 335279780..23c69c710 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,10 +1,28 @@ # Noema Product and Technical Gap Baseline +## Proposed released gateway endpoint admission (2026-10-02) + +Open PR #737 proposes an exact-membership gate for every token-bearing +contextual-orchestrator consumer. `NOEMA_LLM_API_URL_ALLOWLIST_JSON` is a +non-empty unordered JSON set populated from an immutable released CO endpoint +contract; the selected `NOEMA_LLM_API_URL` must be a canonical HTTPS `/v1` +member before health I/O or OpenCode/Python client construction. This closes +the prior denylist/self-asserted-health gap without moving provider/model +eligibility out of CO. Completion evidence remains non-terminal. + +The active #737 candidate also repairs scanner acquisition reliability after +an exact-head patch-validator-image run received repeated GitHub release-asset +HTTP 500 responses. The checksum-authenticated HTTPS downloader now uses a +bounded exponential retry within its existing outer timeout. Status: +**Ready / Proposed / merge HOLD** pending fresh exact-head CI, reviewer, +Security Scan, image/SBOM receipts, immutable CO endpoint evidence, qualifying +independent approval, and ordinary merge; queued is not GREEN. + ## Authority and update rule 이 문서는 protected source, active candidate, transient workflow evidence와 foreign-owner authority를 분리한다. Open PR exact head, protected base, required workflow, review thread, release와 central dependency는 mutation·merge·release 직전에 다시 읽는다. predecessor GREEN, queued/pending/in_progress/skipped/cancelled run, 오래된 PR base snapshot과 scanner/model judgement는 다음 revision의 merge authority로 전용하지 않는다. queued는 GREEN이 아니다. -Current protected source는 mutation·merge·release 시점에 live protected `main`을 다시 조회해 결정한다. 이 문서 안의 exact source SHA는 dated observation 또는 protected history일 뿐 future merge 뒤 evergreen current authority로 사용하지 않는다. Dated protected observation for this repair는 `main@4433c3009d4c6bc900bf5c8346f75b07185ff985`이다. #714의 GitHub-verified normal merge identity `159758c16f92dafb884d176fdbf23cb0f5bd69f7`, #717의 GitHub-verified normal merge identity `067ab5045cc16f4096751a03a5fdd19e13a8aa42`, 그리고 #719의 GitHub-verified normal merge identity `4433c3009d4c6bc900bf5c8346f75b07185ff985`는 protected history에 포함되며 이후 protected-main 전진을 이 문서의 evergreen authority로 전용하지 않는다. #719의 reviewed source exact는 `de91e24fdac66c9963c92ca77971d870a48778e4`다. #681의 GitHub-verified normal merge identity `1fd5d80018837d374bb3776f46ebed54db4dc6af`, #678의 GitHub-verified normal merge identity `a49d8532df6ac4d2eead11867047c1c4d0fa3359`, #683의 GitHub-verified normal merge identity `20452140e6d6f91e92f2491c15f53e8eef0dc6f8`, #685의 GitHub-verified normal merge identity `0ba3fe2f484cbd8e6f6f8b32df4768cba9a8baa2`, #687의 GitHub-verified normal merge identity `f9d08f4a78f55989b58eb8b12d152933e40cc807`, #689의 GitHub-verified normal merge identity `620b29fae9a5dad5d463aff83dd6adb6b67f9305`, #708의 GitHub-verified normal merge identity `2026878cc4627ef2cc781c9aa68da0e11b066fd1`, 그리고 #710의 GitHub-verified normal merge identity `de0f3b5a9b5040ce4700a0888539f3d4f1d723bc`도 protected history에 포함된다. +Current protected source는 mutation·merge·release 시점에 live protected `main`을 다시 조회해 결정한다. 이 문서 안의 exact source SHA는 dated observation 또는 protected history일 뿐 future merge 뒤 evergreen current authority로 사용하지 않는다. Dated protected observation for this repair는 `main@1204a075ac15b924e2f907804ba99c337d1a3ac7`이다. #714의 GitHub-verified normal merge identity `159758c16f92dafb884d176fdbf23cb0f5bd69f7`, #717의 GitHub-verified normal merge identity `067ab5045cc16f4096751a03a5fdd19e13a8aa42`, 그리고 #719의 GitHub-verified normal merge identity `4433c3009d4c6bc900bf5c8346f75b07185ff985`는 protected history에 포함되며 이후 protected-main 전진을 이 문서의 evergreen authority로 전용하지 않는다. #719의 reviewed source exact는 `de91e24fdac66c9963c92ca77971d870a48778e4`다. #681의 GitHub-verified normal merge identity `1fd5d80018837d374bb3776f46ebed54db4dc6af`, #678의 GitHub-verified normal merge identity `a49d8532df6ac4d2eead11867047c1c4d0fa3359`, #683의 GitHub-verified normal merge identity `20452140e6d6f91e92f2491c15f53e8eef0dc6f8`, #685의 GitHub-verified normal merge identity `0ba3fe2f484cbd8e6f6f8b32df4768cba9a8baa2`, #687의 GitHub-verified normal merge identity `f9d08f4a78f55989b58eb8b12d152933e40cc807`, #689의 GitHub-verified normal merge identity `620b29fae9a5dad5d463aff83dd6adb6b67f9305`, #708의 GitHub-verified normal merge identity `2026878cc4627ef2cc781c9aa68da0e11b066fd1`, 그리고 #710의 GitHub-verified normal merge identity `de0f3b5a9b5040ce4700a0888539f3d4f1d723bc`도 protected history에 포함된다. Dated central control-plane observation for this repair는 central `.github/main@cb0872c9a20d5584703dffacca65c096fc034c6c`다. Noema runtime의 reviewed immutable central consumer pin은 `c9052e607e5f3cc76e73207e7786b21500721b79`이고 runtime authority 표현은 `ALLOWED_WORKFLOW_SHA = c9052e607e5f3cc76e73207e7786b21500721b79`다. Moving foreign head와 reviewed immutable pin을 같은 권위로 취급하지 않으며 central moving head가 전진했다고 consumer pin을 자동 승격하지 않는다. @@ -133,6 +151,7 @@ PR 0은 useful work를 닫아 제조하지 않는다. Open lane은 normal merge | P0 | Durable workflow/state production evidence | source Durable Object logic·object-size observation이 deployed recovery/SLO로 오인될 위험 | protected #542 + #605 / ADR 0013 / issue #541 | source + exact-object observation integrated; ADR 0013 Proposed | immutable release/deployment + exact-object transaction/restart/recovery + representative storage-growth denominator + p95 + PITR/rollback | approved deployment owner에서 exact protected release 대상으로 runtime/recovery receipt 확보 | | P0 | Protected-main governance closure | Security workflow 하나로 PR/review/history/deletion/bypass 통제를 과대 주장할 위험 | issue #27 | external control evidence open | live ruleset + PR/review/conversation/history/deletion + bypass evidence | admin/owner control을 독립 검증 | | P0 | Patch-validator operational publication | PR image CI가 immutable runtime publication으로 오인될 위험 | issue #66 | source/image integrated; publication open | protected-main execution + immutable image/signature/SBOM/provenance/rollback | operational receipt 뒤 publication/signing 검증 | +| P0 | Patch-validator bundled Undici vulnerability | protected Node 24.19.0 static image의 bundled Undici 7.29.0/GHSA-3wwx-pv8p-q78v가 exact-image scanner gate를 차단 | issue #734; active PR #737; dependent Draft PRs #733/#736 | PR #737 active candidate에 final receipt exact Undici assertion, released gateway endpoint admission, review-contract repair와 bounded scanner acquisition reliability repair가 통합됐지만 fresh exact-head evidence와 qualifying independent approval은 non-terminal | exact PR-head build/smoke + Node/Undici inventory + final verifier exact Undici assertion + raw scanner receipt with no blocking finding + full CI/typecheck/coverage + independent approval + ordinary merge + immutable release evidence | PR #737 fresh exact-head review/check를 통과시킨 뒤 ordinary merge; #733/#736은 prerequisite integration과 release 전 Draft 유지 | | P0 | Authentic production KPI evidence | synthetic/source KPI가 실제 운영 성능으로 오인될 위험 | issue #3 | >=30-day production window absent | authenticated production bytes + provenance + strict KPI gate | 실제 production evidence만 수집 | | P0 | Acquisition coordination | source/docs completion이 buyer/legal/transfer readiness로 오인될 위험 | issue #5 | evidence families incomplete | exact release/deployment/operational/legal evidence | owner별 evidence family 수렴 | | P0 | External Maintainer/Reviewer App identity | source preflight가 실제 App installation/reviewer authority로 오인될 위험 | issues #29 / #227 | live identity evidence absent | installation/key custody/permission/reviewer eligibility | external control-plane에서 독립 검증 | diff --git a/package.json b/package.json index 3f8b58d59..482c15713 100644 --- a/package.json +++ b/package.json @@ -3,19 +3,19 @@ "version": "0.1.0", "private": true, "type": "module", - "packageManager": "npm@11.17.0", + "packageManager": "npm@11.19.0", "engines": { "node": ">=22" }, "devEngines": { "runtime": { "name": "node", - "version": "24.19.0", + "version": "24.21.0", "onFail": "error" }, "packageManager": { "name": "npm", - "version": "11.17.0", + "version": "11.19.0", "onFail": "error" } }, diff --git a/reviewer/README.md b/reviewer/README.md index 96ae58288..d136455b8 100644 --- a/reviewer/README.md +++ b/reviewer/README.md @@ -230,6 +230,7 @@ KV-first, with the CI secret environment as bootstrap transport only - `NOEMA_LLM_MODEL` - `NOEMA_LLM_API_URL` +- `NOEMA_LLM_API_URL_ALLOWLIST_JSON` (non-empty unordered set of exact released `/v1` endpoints) - `NOEMA_LLM_API_KEY` - `NOEMA_LLM_ZDR_ONLY` (`true` or `false`; request-level privacy policy) diff --git a/reviewer/noema_reviewer/config.py b/reviewer/noema_reviewer/config.py index b1561d405..96b1352fe 100644 --- a/reviewer/noema_reviewer/config.py +++ b/reviewer/noema_reviewer/config.py @@ -18,16 +18,18 @@ from __future__ import annotations +import json import os +import re from collections.abc import Callable from dataclasses import dataclass -from urllib.parse import urlsplit +from ipaddress import ip_address +from urllib.parse import unquote, urlsplit, urlunsplit from pydantic_ai.models import Model CredentialGetter = Callable[[str], str | None] -_LOOPBACK_MODEL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"}) _DIRECT_PROVIDER_HOSTS = frozenset( { "api.openai.com", @@ -43,6 +45,9 @@ "NOEMA_LLM_REQUEST_TIMEOUT_SECONDS", "NOEMA_LLM_MAX_RETRIES", ) +_NONCANONICAL_NUMERIC_HOST = re.compile( + r"^(?:0[xX][0-9A-Fa-f]+|[0-9]+)(?:\.(?:0[xX][0-9A-Fa-f]+|[0-9]+))*$" +) @dataclass(frozen=True) @@ -52,6 +57,7 @@ class ReviewerConfig: model_name: str base_url: str api_key: str + allowed_base_urls: tuple[str, ...] zdr_only: bool = False @@ -64,6 +70,15 @@ def _read(name: str, credential_getter: CredentialGetter | None) -> str: return (os.environ.get(name) or "").strip() +def _read_exact(name: str, credential_getter: CredentialGetter | None) -> str: + """Read endpoint authority without silently canonicalizing whitespace.""" + if credential_getter is not None: + value = credential_getter(name) + if value: + return value + return os.environ.get(name) or "" + + def _read_zdr_policy(credential_getter: CredentialGetter | None) -> bool: """Parse the trusted request-level privacy policy without truthy coercion.""" raw = _read("NOEMA_LLM_ZDR_ONLY", credential_getter) @@ -92,23 +107,37 @@ def _require_single_routing_alias(name: str, value: str) -> None: raise RuntimeError(f"{name} must equal {_CANONICAL_ROUTING_ALIAS}") -def _require_safe_model_endpoint(name: str, value: str) -> None: +def _canonical_model_endpoint(name: str, value: str) -> str: """Require the reviewed gateway URL shape before a credential can be attached.""" try: parsed = urlsplit(value) hostname = parsed.hostname username = parsed.username password = parsed.password + port = parsed.port except ValueError as exc: raise RuntimeError(f"{name} must be a valid model endpoint URL") from exc - normalized_hostname = (hostname or "").lower().rstrip(".") + raw_hostname = hostname or "" + try: + normalized_hostname = raw_hostname.encode("idna").decode("ascii").lower().rstrip(".") + except UnicodeError as exc: + raise RuntimeError(f"{name} must be a valid model endpoint URL") from exc if not normalized_hostname: raise RuntimeError(f"{name} must be a valid model endpoint URL") + if ( + raw_hostname.endswith(".") + or "*" in value + or "%" in parsed.netloc + or "\\" in value + ): + raise RuntimeError(f"{name} must be an exact canonical model endpoint URL") if username is not None or password is not None or parsed.query or parsed.fragment: raise RuntimeError(f"{name} must not contain userinfo, query, or fragment") path = parsed.path.rstrip("/") + if any(unquote(segment) in {".", ".."} for segment in parsed.path.split("/")): + raise RuntimeError(f"{name} must be an exact canonical model endpoint URL") if not path.endswith("/v1"): raise RuntimeError(f"{name} must end in /v1") @@ -117,11 +146,81 @@ def _require_safe_model_endpoint(name: str, value: str) -> None: f"{name} must target contextual-orchestrator, not a direct model provider" ) - if parsed.scheme == "https": - return - if parsed.scheme == "http" and normalized_hostname in _LOOPBACK_MODEL_HOSTS: - return - raise RuntimeError(f"{name} must use HTTPS except for a loopback development endpoint") + try: + parsed_ip = ip_address(normalized_hostname) + if getattr(parsed_ip, "ipv4_mapped", None) is not None: + raise RuntimeError( + f"{name} must not use an IPv4-mapped IPv6 endpoint" + ) + loopback_ip = parsed_ip.is_loopback + normalized_hostname = parsed_ip.compressed + except ValueError: + if _NONCANONICAL_NUMERIC_HOST.fullmatch(normalized_hostname): + raise RuntimeError( + f"{name} must be an exact canonical model endpoint URL" + ) + loopback_ip = False + if ( + normalized_hostname == "localhost" + or normalized_hostname.endswith(".localhost") + or loopback_ip + ): + raise RuntimeError(f"{name} must not target a loopback endpoint") + + if parsed.scheme != "https": + raise RuntimeError(f"{name} must use HTTPS") + canonical_hostname = ( + f"[{normalized_hostname}]" if ":" in normalized_hostname else normalized_hostname + ) + canonical_netloc = canonical_hostname + if port is not None and port != 443: + canonical_netloc = f"{canonical_hostname}:{port}" + canonical = urlunsplit(("https", canonical_netloc, path, "", "")) + if value != canonical: + raise RuntimeError(f"{name} must be an exact canonical model endpoint URL") + return canonical + + +def _parse_model_endpoint_allowlist(raw_json: str) -> tuple[str, ...]: + """Parse exact released gateway endpoints without adding routing order.""" + try: + decoded = json.loads(raw_json) + except (TypeError, json.JSONDecodeError) as exc: + raise RuntimeError( + "NOEMA_LLM_API_URL_ALLOWLIST_JSON must be a non-empty JSON array " + "of exact released endpoints" + ) from exc + if not isinstance(decoded, list) or not decoded: + raise RuntimeError( + "NOEMA_LLM_API_URL_ALLOWLIST_JSON must be a non-empty JSON array " + "of exact released endpoints" + ) + canonical: list[str] = [] + for value in decoded: + if not isinstance(value, str) or not value: + raise RuntimeError( + "NOEMA_LLM_API_URL_ALLOWLIST_JSON members must be exact released endpoint URLs" + ) + endpoint = _canonical_model_endpoint( + "NOEMA_LLM_API_URL_ALLOWLIST_JSON", value + ) + if endpoint in canonical: + raise RuntimeError( + "NOEMA_LLM_API_URL_ALLOWLIST_JSON must not contain duplicate endpoints" + ) + canonical.append(endpoint) + return tuple(canonical) + + +def _require_safe_model_endpoint( + name: str, value: str, allowed_base_urls: tuple[str, ...] +) -> None: + """Require exact membership in the released gateway endpoint set.""" + endpoint = _canonical_model_endpoint(name, value) + if endpoint not in allowed_base_urls: + raise RuntimeError( + f"{name} is not an allowed released contextual-orchestrator endpoint" + ) def resolve_config(credential_getter: CredentialGetter | None = None) -> ReviewerConfig: @@ -137,7 +236,10 @@ def resolve_config(credential_getter: CredentialGetter | None = None) -> Reviewe routing, attempt-allocation, privacy, or transport contract drifts. """ model_name = _read("NOEMA_LLM_MODEL", credential_getter) - base_url = _read("NOEMA_LLM_API_URL", credential_getter) + base_url = _read_exact("NOEMA_LLM_API_URL", credential_getter) + allowlist_json = _read_exact( + "NOEMA_LLM_API_URL_ALLOWLIST_JSON", credential_getter + ) api_key = _read("NOEMA_LLM_API_KEY", credential_getter) _reject_legacy_attempt_controls(credential_getter) zdr_only = _read_zdr_policy(credential_getter) @@ -155,6 +257,7 @@ def resolve_config(credential_getter: CredentialGetter | None = None) -> Reviewe for name, value in ( ("NOEMA_LLM_MODEL", model_name), ("NOEMA_LLM_API_URL", base_url), + ("NOEMA_LLM_API_URL_ALLOWLIST_JSON", allowlist_json), ("NOEMA_LLM_API_KEY", api_key), ) if not value @@ -173,11 +276,13 @@ def resolve_config(credential_getter: CredentialGetter | None = None) -> Reviewe "the fail-closed zero-cost ZDR-first pool." ) _require_single_routing_alias("NOEMA_LLM_MODEL", model_name) - _require_safe_model_endpoint("NOEMA_LLM_API_URL", base_url) + allowed_base_urls = _parse_model_endpoint_allowlist(allowlist_json) + _require_safe_model_endpoint("NOEMA_LLM_API_URL", base_url, allowed_base_urls) return ReviewerConfig( model_name=model_name, base_url=base_url, api_key=api_key, + allowed_base_urls=allowed_base_urls, zdr_only=zdr_only, ) @@ -190,7 +295,9 @@ def resolve_model(config: ReviewerConfig | None = None) -> Model: resolved = config or resolve_config() _require_single_routing_alias("NOEMA_LLM_MODEL", resolved.model_name) - _require_safe_model_endpoint("NOEMA_LLM_API_URL", resolved.base_url) + _require_safe_model_endpoint( + "NOEMA_LLM_API_URL", resolved.base_url, resolved.allowed_base_urls + ) client = AsyncOpenAI( base_url=resolved.base_url, diff --git a/reviewer/tests/test_agent.py b/reviewer/tests/test_agent.py index 0c4395789..44ed3c0c0 100644 --- a/reviewer/tests/test_agent.py +++ b/reviewer/tests/test_agent.py @@ -57,6 +57,7 @@ def _config(*, zdr_only: bool = False) -> ReviewerConfig: model_name="orchestrator/free", base_url="https://orchestrator.example/v1", api_key="gateway-token", + allowed_base_urls=("https://orchestrator.example/v1",), zdr_only=zdr_only, ) diff --git a/reviewer/tests/test_config.py b/reviewer/tests/test_config.py index 755426729..a5db3dc8e 100644 --- a/reviewer/tests/test_config.py +++ b/reviewer/tests/test_config.py @@ -2,6 +2,8 @@ from __future__ import annotations +import json + import pytest from pydantic_ai.models.openai import OpenAIChatModel @@ -10,7 +12,152 @@ def _kv(values: dict[str, str]): """Build a credential getter backed by a dict.""" - return lambda name: values.get(name) + transport = dict(values) + if ( + "NOEMA_LLM_API_URL" in transport + and "NOEMA_LLM_API_URL_ALLOWLIST_JSON" not in transport + ): + transport["NOEMA_LLM_API_URL_ALLOWLIST_JSON"] = json.dumps( + [transport["NOEMA_LLM_API_URL"]] + ) + return lambda name: transport.get(name) + + +def test_resolve_config_rejects_self_identified_endpoint_outside_allowlist() -> None: + """An arbitrary HTTPS endpoint cannot receive the dedicated gateway token.""" + values = { + "NOEMA_LLM_MODEL": "orchestrator/free", + "NOEMA_LLM_API_URL": "https://attacker.example/v1", + "NOEMA_LLM_API_URL_ALLOWLIST_JSON": json.dumps( + ["https://orchestrator.example/v1"] + ), + "NOEMA_LLM_API_KEY": "must-not-appear", + } + with pytest.raises(RuntimeError, match="NOEMA_LLM_API_URL") as excinfo: + resolve_config(_kv(values)) + assert "must-not-appear" not in str(excinfo.value) + + +@pytest.mark.parametrize( + "allowlist_json", + ( + "", + "{", + "{}", + "[]", + '["https://orchestrator.example/v1", 1]', + '["https://orchestrator.example/v1", "https://orchestrator.example/v1"]', + '["https://*.example/v1"]', + '["https://ORCHESTRATOR.example/v1"]', + '["https://orchestrator.example/v1/"]', + '["https://orchestrator.example:443/v1"]', + '["https://orchestrator.example:99999/v1"]', + '["https://éxample.example/v1"]', + '[" https://orchestrator.example/v1 "]', + '["https://orchestrator.example./v1"]', + '["http://127.0.0.1:18080/v1"]', + '["https://127.0.0.2/v1"]', + '["https://127.1/v1"]', + '["https://2130706433/v1"]', + '["https://0x7f000001/v1"]', + '["https://0177.0.0.1/v1"]', + '["https://[0:0:0:0:0:0:0:1]/v1"]', + '["https://localhost/v1"]', + '["https://[2001:0db8:0:0:0:0:0:1]/v1"]', + '["https://orchestrator.example/./v1"]', + '["https://orchestrator.example/x/../v1"]', + '["https://api.openai.com/v1"]', + '["https://%61pi.openai.com/v1"]', + '["https://api%2eopenai.com/v1"]', + '["https://[fe80::1%25eth0]/v1"]', + '["https://orchestrator.example/a\\\\b/v1"]', + '["https://orchestrator.example\\\\evil.example/v1"]', + '["https://foo..example/v1"]', + f'["https://{"a" * 64}.example/v1"]', + '["https://[::ffff:192.0.2.1]/v1"]', + '["https://[::ffff:c000:201]/v1"]', + ), +) +def test_resolve_config_rejects_non_authoritative_endpoint_allowlist( + allowlist_json: str, +) -> None: + """Malformed or non-canonical endpoint authority fails before token use.""" + values = { + "NOEMA_LLM_MODEL": "orchestrator/free", + "NOEMA_LLM_API_URL": "https://orchestrator.example/v1", + "NOEMA_LLM_API_URL_ALLOWLIST_JSON": allowlist_json, + "NOEMA_LLM_API_KEY": "must-not-appear", + } + with pytest.raises(RuntimeError, match="NOEMA_LLM_API_URL_ALLOWLIST_JSON") as excinfo: + resolve_config(_kv(values)) + assert "must-not-appear" not in str(excinfo.value) + + +@pytest.mark.parametrize( + "base_url", + ( + " https://orchestrator.example/v1 ", + "https://ORCHESTRATOR.example/v1", + "https://orchestrator.example./v1", + "https://orchestrator.example/v1/", + "https://orchestrator.example:443/v1", + "https://éxample.example/v1", + "https://[2001:0db8:0:0:0:0:0:1]/v1", + "https://orchestrator.example/./v1", + "https://orchestrator.example/x/../v1", + "https://127.1/v1", + "https://2130706433/v1", + "https://0x7f000001/v1", + "https://0177.0.0.1/v1", + "https://%61pi.openai.com/v1", + "https://api%2eopenai.com/v1", + "https://[fe80::1%25eth0]/v1", + "https://orchestrator.example/a\\b/v1", + "https://orchestrator.example\\evil.example/v1", + "https://foo..example/v1", + f'https://{"a" * 64}.example/v1', + "https://[::ffff:192.0.2.1]/v1", + "https://[::ffff:c000:201]/v1", + ), +) +def test_resolve_config_rejects_noncanonical_selected_endpoint(base_url: str) -> None: + """Selected endpoint identity is exact rather than silently normalized.""" + values = { + "NOEMA_LLM_MODEL": "orchestrator/free", + "NOEMA_LLM_API_URL": base_url, + "NOEMA_LLM_API_URL_ALLOWLIST_JSON": '["https://orchestrator.example/v1"]', + "NOEMA_LLM_API_KEY": "must-not-appear", + } + with pytest.raises(RuntimeError, match="NOEMA_LLM_API_URL") as excinfo: + resolve_config(_kv(values)) + assert "must-not-appear" not in str(excinfo.value) + + +def test_resolve_config_accepts_exact_nondefault_https_port() -> None: + """A released endpoint may use an explicitly allowlisted canonical HTTPS port.""" + endpoint = "https://orchestrator.example:8443/v1" + values = { + "NOEMA_LLM_MODEL": "orchestrator/free", + "NOEMA_LLM_API_URL": endpoint, + "NOEMA_LLM_API_URL_ALLOWLIST_JSON": json.dumps([endpoint]), + "NOEMA_LLM_API_KEY": "gateway-token", + } + config = resolve_config(_kv(values)) + assert config.base_url == endpoint + assert config.allowed_base_urls == (endpoint,) + + +def test_resolve_model_rechecks_manually_constructed_endpoint_membership() -> None: + """Manual ReviewerConfig construction cannot bypass released endpoint admission.""" + config = ReviewerConfig( + model_name="orchestrator/free", + base_url="https://attacker.example/v1", + api_key="must-not-appear", + allowed_base_urls=("https://orchestrator.example/v1",), + ) + with pytest.raises(RuntimeError, match="NOEMA_LLM_API_URL") as excinfo: + resolve_model(config) + assert "must-not-appear" not in str(excinfo.value) def test_resolve_config_prefers_credential_getter() -> None: @@ -27,6 +174,7 @@ def test_resolve_config_prefers_credential_getter() -> None: model_name="orchestrator/free", base_url="https://orchestrator.example/v1", api_key="secret", + allowed_base_urls=("https://orchestrator.example/v1",), ) @@ -34,6 +182,7 @@ def test_resolve_config_falls_back_to_env(monkeypatch) -> None: """Env transport supplies values when the KV getter has none.""" monkeypatch.setenv("NOEMA_LLM_MODEL", "orchestrator/free") monkeypatch.setenv("NOEMA_LLM_API_URL", "https://x/v1") + monkeypatch.setenv("NOEMA_LLM_API_URL_ALLOWLIST_JSON", '["https://x/v1"]') monkeypatch.setenv("NOEMA_LLM_API_KEY", "k") config = resolve_config() assert config.model_name == "orchestrator/free" @@ -43,6 +192,7 @@ def test_resolve_config_getter_miss_falls_back_to_env(monkeypatch) -> None: """When the KV getter has no value for a key, env transport supplies it.""" monkeypatch.setenv("NOEMA_LLM_MODEL", "orchestrator/free") monkeypatch.setenv("NOEMA_LLM_API_URL", "https://env/v1") + monkeypatch.setenv("NOEMA_LLM_API_URL_ALLOWLIST_JSON", '["https://env/v1"]') monkeypatch.setenv("NOEMA_LLM_API_KEY", "env-key") config = resolve_config(_kv({})) assert config.model_name == "orchestrator/free" @@ -50,7 +200,12 @@ def test_resolve_config_getter_miss_falls_back_to_env(monkeypatch) -> None: def test_resolve_config_raises_when_unconfigured(monkeypatch) -> None: """A missing setting raises loudly and names what is missing.""" - for name in ("NOEMA_LLM_MODEL", "NOEMA_LLM_API_URL", "NOEMA_LLM_API_KEY"): + for name in ( + "NOEMA_LLM_MODEL", + "NOEMA_LLM_API_URL", + "NOEMA_LLM_API_URL_ALLOWLIST_JSON", + "NOEMA_LLM_API_KEY", + ): monkeypatch.delenv(name, raising=False) with pytest.raises(RuntimeError) as excinfo: resolve_config() @@ -60,7 +215,10 @@ def test_resolve_config_raises_when_unconfigured(monkeypatch) -> None: def test_resolve_model_builds_openai_model() -> None: """resolve_model builds one OpenAI-compatible gateway model from config.""" config = ReviewerConfig( - model_name="orchestrator/free", base_url="https://x/v1", api_key="k" + model_name="orchestrator/free", + base_url="https://x/v1", + api_key="k", + allowed_base_urls=("https://x/v1",), ) model = resolve_model(config) assert isinstance(model, OpenAIChatModel) @@ -128,6 +286,10 @@ def test_resolve_config_rejects_leftover_fallback_from_env_transport(monkeypatch """Env-transport leftover fallback keys fail closed when no KV getter is used.""" monkeypatch.setenv("NOEMA_LLM_MODEL", "orchestrator/free") monkeypatch.setenv("NOEMA_LLM_API_URL", "https://primary.example/v1") + monkeypatch.setenv( + "NOEMA_LLM_API_URL_ALLOWLIST_JSON", + '["https://primary.example/v1"]', + ) monkeypatch.setenv("NOEMA_LLM_API_KEY", "primary-key") monkeypatch.setenv("NOEMA_FALLBACK_LLM_MODEL", "openai/gpt-4.1") with pytest.raises(RuntimeError, match="sequential model fallback is not allowed") as excinfo: @@ -235,11 +397,13 @@ def test_resolve_config_rejects_malformed_model_endpoint_with_bounded_error() -> model_name="orchestrator/free", base_url="http://reviewer-gateway.example/v1", api_key="primary-key", + allowed_base_urls=("http://reviewer-gateway.example/v1",), ), ReviewerConfig( model_name="openai/gpt-4.1", base_url="https://primary.example/v1", api_key="primary-key", + allowed_base_urls=("https://primary.example/v1",), ), ], ) @@ -253,19 +417,23 @@ def test_resolve_model_reads_live_config_when_none_is_passed(monkeypatch) -> Non """Omitting config still resolves the single gateway model from transport.""" monkeypatch.setenv("NOEMA_LLM_MODEL", "orchestrator/free") monkeypatch.setenv("NOEMA_LLM_API_URL", "https://orchestrator.example/v1") + monkeypatch.setenv( + "NOEMA_LLM_API_URL_ALLOWLIST_JSON", + '["https://orchestrator.example/v1"]', + ) monkeypatch.setenv("NOEMA_LLM_API_KEY", "gateway-token") model = resolve_model() assert isinstance(model, OpenAIChatModel) @pytest.mark.parametrize("host", ["localhost", "127.0.0.1", "[::1]"]) -def test_resolve_config_allows_loopback_http_model_endpoint(host: str) -> None: - """Local development may use plaintext HTTP only on an exact loopback host.""" +def test_resolve_config_rejects_loopback_model_endpoint(host: str) -> None: + """A loopback endpoint cannot enter the released gateway allowlist.""" expected_url = f"http://{host}:8080/v1" values = { "NOEMA_LLM_MODEL": "orchestrator/free", "NOEMA_LLM_API_URL": expected_url, "NOEMA_LLM_API_KEY": "local-only-key", } - config = resolve_config(_kv(values)) - assert config.base_url == expected_url + with pytest.raises(RuntimeError, match="loopback endpoint"): + resolve_config(_kv(values)) diff --git a/reviewer/tests/test_gateway_endpoint_contract.py b/reviewer/tests/test_gateway_endpoint_contract.py index c77d4e6e1..d7028c239 100644 --- a/reviewer/tests/test_gateway_endpoint_contract.py +++ b/reviewer/tests/test_gateway_endpoint_contract.py @@ -2,6 +2,8 @@ from __future__ import annotations +import json + import pytest from noema_reviewer.config import resolve_config @@ -12,6 +14,7 @@ def _config(base_url: str) -> dict[str, str]: return { "NOEMA_LLM_MODEL": "orchestrator/free", "NOEMA_LLM_API_URL": base_url, + "NOEMA_LLM_API_URL_ALLOWLIST_JSON": json.dumps([base_url]), "NOEMA_LLM_API_KEY": "gateway-token", } diff --git a/reviewer/tests/test_no_heuristic_gateway_policy.py b/reviewer/tests/test_no_heuristic_gateway_policy.py index ed6a0d577..d4627e28f 100644 --- a/reviewer/tests/test_no_heuristic_gateway_policy.py +++ b/reviewer/tests/test_no_heuristic_gateway_policy.py @@ -3,6 +3,7 @@ from __future__ import annotations import inspect +import json import pytest @@ -14,7 +15,15 @@ def _kv(values: dict[str, str]): """Build a credential getter backed by a dict.""" - return lambda name: values.get(name) + transport = dict(values) + if ( + "NOEMA_LLM_API_URL" in transport + and "NOEMA_LLM_API_URL_ALLOWLIST_JSON" not in transport + ): + transport["NOEMA_LLM_API_URL_ALLOWLIST_JSON"] = json.dumps( + [transport["NOEMA_LLM_API_URL"]] + ) + return lambda name: transport.get(name) def test_reviewer_accepts_only_the_canonical_free_pool() -> None: diff --git a/scripts/lib/orchestrator-gateway.mjs b/scripts/lib/orchestrator-gateway.mjs index a0ad70720..9ab00ff53 100644 --- a/scripts/lib/orchestrator-gateway.mjs +++ b/scripts/lib/orchestrator-gateway.mjs @@ -1,4 +1,5 @@ import { mkdirSync, writeFileSync } from "node:fs"; +import { BlockList, isIP } from "node:net"; import { dirname } from "node:path"; import { hasDuplicateJsonObjectKeys } from "../normalize-commercial-readiness-evidence.mjs"; @@ -14,9 +15,13 @@ const DIRECT_PROVIDER_HOSTS = Object.freeze([ "api.nvidia.com", "api.bytez.com", ]); +const LOOPBACK_ENDPOINTS = new BlockList(); +LOOPBACK_ENDPOINTS.addSubnet("127.0.0.0", 8, "ipv4"); +LOOPBACK_ENDPOINTS.addAddress("::1", "ipv6"); const OPENCODE_PROVIDER_ID = "contextual-orchestrator"; const NON_SECRET_TRANSPORT_NAMES = new Set([ "NOEMA_LLM_API_URL", + "NOEMA_LLM_API_URL_ALLOWLIST_JSON", "NOEMA_LLM_MODEL", ]); const FORBIDDEN_PROVIDER_KEYS = Object.freeze([ @@ -103,7 +108,7 @@ export function orchestratorGatewayConsumers() { export function orchestratorGatewayConsumerContract() { return Object.freeze({ id: "contextual-orchestrator-gateway", - version: 1, + version: 2, service: "contextual-orchestrator", routing_alias: DEFAULT_ROUTING_ALIAS, api_url: Object.freeze({ @@ -112,6 +117,10 @@ export function orchestratorGatewayConsumerContract() { allow_userinfo: false, allow_query: false, allow_fragment: false, + require_canonical_serialization: true, + allow_loopback: false, + exact_released_endpoint_membership: true, + allowlist_order_has_routing_meaning: false, }), healthz: Object.freeze({ unauthenticated: true, @@ -123,6 +132,7 @@ export function orchestratorGatewayConsumerContract() { }), transport_names: Object.freeze({ api_url: "NOEMA_LLM_API_URL", + api_url_allowlist: "NOEMA_LLM_API_URL_ALLOWLIST_JSON", model: "NOEMA_LLM_MODEL", api_key: "NOEMA_LLM_API_KEY", }), @@ -154,7 +164,7 @@ export function serializeOrchestratorGatewayConsumerContract() { * * @param {NodeJS.ProcessEnv | Record} source Transport map. * @param {string} name Setting name. - * @returns {string} Trimmed value, or an empty string when absent. + * @returns {string} Exact endpoint/allowlist value, trimmed model, or empty when absent. * @throws {Error} When `name` is not an approved non-secret gateway setting. */ export function readGatewayTransportValue(source, name) { @@ -162,7 +172,8 @@ export function readGatewayTransportValue(source, name) { throw new Error("gateway preflight may read only non-secret gateway settings"); } const raw = source?.[name]; - return typeof raw === "string" ? raw.trim() : ""; + if (typeof raw !== "string") return ""; + return name === "NOEMA_LLM_MODEL" ? raw.trim() : raw; } /** @@ -182,38 +193,61 @@ export function boundedGatewayError(error) { /** * Parse and accept only an HTTPS OpenAI-compatible gateway base URL ending in /v1. * - * @param {string} rawUrl Candidate `NOEMA_LLM_API_URL`. + * @param {string} rawUrl Candidate endpoint URL. + * @param {string} [name] Configuration name used in bounded diagnostics. * @returns {{ href: string, healthzUrl: string, hostname: string }} Canonical URL parts. * @throws {Error} When the URL is not the production gateway contract. */ -export function parseOrchestratorGatewayUrl(rawUrl) { - const apiUrl = String(rawUrl ?? "").trim(); +export function parseOrchestratorGatewayUrl(rawUrl, name = "NOEMA_LLM_API_URL") { + const apiUrl = String(rawUrl ?? ""); let parsed; try { parsed = new URL(apiUrl); } catch { - throw new Error("NOEMA_LLM_API_URL must be an absolute HTTPS URL"); + throw new Error(`${name} must be an absolute HTTPS URL`); } if (parsed.protocol !== "https:") { - throw new Error("NOEMA_LLM_API_URL must be an absolute HTTPS URL"); + throw new Error(`${name} must be an absolute HTTPS URL`); } if (parsed.username || parsed.password || parsed.search || parsed.hash) { throw new Error( - "NOEMA_LLM_API_URL must not contain credentials, query, or fragment", + `${name} must not contain credentials, query, or fragment`, ); } - const hostname = parsed.hostname.toLowerCase().replace(/\.+$/u, ""); - if (!hostname) { - throw new Error("NOEMA_LLM_API_URL must be an absolute HTTPS URL"); + const rawHostname = parsed.hostname.toLowerCase(); + if (rawHostname.endsWith(".")) { + throw new Error(`${name} must be a canonical endpoint URL`); + } + const hostname = rawHostname.replace(/\.+$/u, ""); + if (!hostname.startsWith("[")) { + const hostnameLabels = hostname.split("."); + if (hostnameLabels.some((label) => !label || label.length > 63)) { + throw new Error(`${name} must be a canonical endpoint URL`); + } } if (DIRECT_PROVIDER_HOSTS.includes(hostname)) { throw new Error( - "Noema production jobs must use contextual-orchestrator, not a direct model provider", + `${name} must use contextual-orchestrator, not a direct model provider`, ); } + const ipHostname = hostname.replace(/^\[|\]$/gu, ""); + const ipVersion = isIP(ipHostname); + if (ipVersion === 6 && ipHostname.startsWith("::ffff:")) { + throw new Error(`${name} must not use an IPv4-mapped IPv6 endpoint`); + } + if ( + hostname === "localhost" || + hostname.endsWith(".localhost") || + (ipVersion !== 0 && LOOPBACK_ENDPOINTS.check( + ipHostname, + ipVersion === 4 ? "ipv4" : "ipv6", + )) + ) { + throw new Error(`${name} must not target a loopback endpoint`); + } const path = parsed.pathname.replace(/\/+$/u, "") || ""; if (!path.endsWith("/v1")) { - throw new Error("NOEMA_LLM_API_URL must end in /v1"); + throw new Error(`${name} must end in /v1`); } const healthPath = `${path.slice(0, -3)}/healthz`; parsed.pathname = path; @@ -221,6 +255,9 @@ export function parseOrchestratorGatewayUrl(rawUrl) { parsed.hash = ""; const healthUrl = new URL(parsed.href); healthUrl.pathname = healthPath; + if (apiUrl !== parsed.href) { + throw new Error(`${name} must be a canonical endpoint URL`); + } return { href: parsed.href, healthzUrl: healthUrl.href, @@ -228,6 +265,69 @@ export function parseOrchestratorGatewayUrl(rawUrl) { }; } +/** + * Parse the released contextual-orchestrator endpoint allowlist. + * + * The array is an admission set only: order never selects an endpoint and + * Noema never retries or falls back across members. + * + * @param {string} rawJson JSON array from `NOEMA_LLM_API_URL_ALLOWLIST_JSON`. + * @returns {readonly string[]} Canonical exact `/v1` endpoint URLs. + * @throws {Error} When the allowlist is absent, malformed, ambiguous, or unsafe. + */ +export function parseOrchestratorGatewayApiUrlAllowlist(rawJson) { + let decoded; + try { + decoded = JSON.parse(String(rawJson ?? "")); + } catch { + throw new Error( + "NOEMA_LLM_API_URL_ALLOWLIST_JSON must be a non-empty JSON array of exact released endpoints", + ); + } + if (!Array.isArray(decoded) || decoded.length === 0) { + throw new Error( + "NOEMA_LLM_API_URL_ALLOWLIST_JSON must be a non-empty JSON array of exact released endpoints", + ); + } + const canonical = []; + for (const value of decoded) { + if (typeof value !== "string" || !value || value.includes("*")) { + throw new Error( + "NOEMA_LLM_API_URL_ALLOWLIST_JSON members must be exact released endpoint URLs", + ); + } + const parsed = parseOrchestratorGatewayUrl( + value, + "NOEMA_LLM_API_URL_ALLOWLIST_JSON", + ); + if (canonical.includes(parsed.href)) { + throw new Error( + "NOEMA_LLM_API_URL_ALLOWLIST_JSON must not contain duplicate endpoints", + ); + } + canonical.push(parsed.href); + } + return Object.freeze(canonical); +} + +/** + * Require the selected gateway URL to be an exact released endpoint member. + * + * @param {string} rawUrl Selected `NOEMA_LLM_API_URL`. + * @param {readonly string[]} allowedApiUrls Parsed released endpoint set. + * @returns {{ href: string, healthzUrl: string, hostname: string }} Canonical URL parts. + * @throws {Error} When the selected URL is not an exact member. + */ +export function requireAllowedOrchestratorGatewayUrl(rawUrl, allowedApiUrls) { + const gateway = parseOrchestratorGatewayUrl(rawUrl); + if (!Array.isArray(allowedApiUrls) || !allowedApiUrls.includes(gateway.href)) { + throw new Error( + "NOEMA_LLM_API_URL is not an allowed released contextual-orchestrator endpoint", + ); + } + return gateway; +} + /** * Resolve the one production routing alias. Empty input becomes the canonical alias. * @@ -462,11 +562,14 @@ export async function verifyOrchestratorHealthz(healthzUrl, options = {}) { * not silently acquire authority; every additional capability must be reviewed * and allowlisted explicitly at this boundary. * - * @param {{ apiUrl: string, model: string }} settings Validated gateway settings. + * @param {{ apiUrl: string, allowedApiUrls: readonly string[], model: string }} settings Validated gateway settings. * @returns {object} OpenCode configuration object. */ export function buildOpenCodeOrchestratorConfig(settings) { - const gateway = parseOrchestratorGatewayUrl(settings.apiUrl); + const gateway = requireAllowedOrchestratorGatewayUrl( + settings.apiUrl, + settings.allowedApiUrls, + ); const model = resolveOrchestratorModel(settings.model); const providerModel = `${OPENCODE_PROVIDER_ID}/${model}`; return { @@ -519,7 +622,7 @@ export function buildOpenCodeOrchestratorConfig(settings) { * Write the owner-only OpenCode config after the gateway URL is validated. * * @param {string} outputPath Destination file. - * @param {{ apiUrl: string, model: string }} settings Validated gateway settings. + * @param {{ apiUrl: string, allowedApiUrls: readonly string[], model: string }} settings Validated gateway settings. * @returns {object} Written configuration. */ export function writeOpenCodeOrchestratorConfig(outputPath, settings) { @@ -548,16 +651,20 @@ export function writeOpenCodeOrchestratorConfig(outputPath, settings) { export async function verifyOrchestratorGatewayContract(input) { const env = input.env ?? {}; const apiUrl = readGatewayTransportValue(env, "NOEMA_LLM_API_URL"); + const allowedApiUrls = parseOrchestratorGatewayApiUrlAllowlist( + readGatewayTransportValue(env, "NOEMA_LLM_API_URL_ALLOWLIST_JSON"), + ); const model = resolveOrchestratorModel( readGatewayTransportValue(env, "NOEMA_LLM_MODEL"), ); - const gateway = parseOrchestratorGatewayUrl(apiUrl); + const gateway = requireAllowedOrchestratorGatewayUrl(apiUrl, allowedApiUrls); await verifyOrchestratorHealthz(gateway.healthzUrl, { fetchImpl: input.fetchImpl, }); if (input.openCodeConfigPath) { writeOpenCodeOrchestratorConfig(input.openCodeConfigPath, { apiUrl: gateway.href, + allowedApiUrls, model, }); } diff --git a/scripts/lib/patch-validator-embedded-runtime-applicability.mjs b/scripts/lib/patch-validator-embedded-runtime-applicability.mjs index 79686f16d..8c773f6b7 100644 --- a/scripts/lib/patch-validator-embedded-runtime-applicability.mjs +++ b/scripts/lib/patch-validator-embedded-runtime-applicability.mjs @@ -19,27 +19,27 @@ const OPENSSL_NON_APPLICABLE_REASON = const NGHTTP2_NON_APPLICABLE_REASON = "CVE affects the nghttpx proxy, not Node's embedded libnghttp2 runtime"; const LEGACY_V8_NON_APPLICABLE_REASON = - "Exact Node 24.19.0 V8 runtime is newer than the reviewed affected legacy V8 releases"; + "Exact Node 24.21.0 V8 runtime is newer than the reviewed affected legacy V8 releases"; const V8_ARRAY_SORT_NON_APPLICABLE_REASON = - "Exact Node 24.19.0 V8 branch lacks the vulnerable inlined Array.prototype.sort reducers"; + "Exact Node 24.21.0 V8 branch lacks the vulnerable inlined Array.prototype.sort reducers"; const SQLITE_NON_APPLICABLE_REASON = - "Exact SQLite 3.53.3 runtime is newer than the reviewed affected SQLite ranges"; + "Exact SQLite 3.53.4 runtime is newer than the reviewed affected SQLite ranges"; const ZLIB_NON_APPLICABLE_REASON = "Advisory applies to the Ruby zlib gem GzipReader wrapper, not Node's embedded C zlib runtime"; -const EXPECTED_NODE_VERSION = "24.19.0"; -const EXPECTED_NGHTTP2_VERSION = "1.69.0"; +const EXPECTED_NODE_VERSION = "24.21.0"; +const EXPECTED_NGHTTP2_VERSION = "1.70.0"; const EXPECTED_NGHTTP2_CPE = - "cpe:2.3:a:nghttp2:nghttp2:1.69.0:*:*:*:*:*:*:*"; -const EXPECTED_V8_VERSION = "13.6.233.17-node.51"; + "cpe:2.3:a:nghttp2:nghttp2:1.70.0:*:*:*:*:*:*:*"; +const EXPECTED_V8_VERSION = "13.6.233.17-node.53"; const EXPECTED_V8_SCANNER_VERSION = "13.6.233.17"; const EXPECTED_V8_CPE = "cpe:2.3:a:google:v8:13.6.233.17:*:*:*:*:*:*:*"; -const EXPECTED_SQLITE_VERSION = "3.53.3"; +const EXPECTED_SQLITE_VERSION = "3.53.4"; const EXPECTED_SQLITE_CPE = - "cpe:2.3:a:sqlite:sqlite:3.53.3:*:*:*:*:*:*:*"; -const EXPECTED_ZLIB_VERSION = "1.3.2.1-motley-3246f1b"; + "cpe:2.3:a:sqlite:sqlite:3.53.4:*:*:*:*:*:*:*"; +const EXPECTED_ZLIB_VERSION = "1.3.2.1-motley-8002e91"; const EXPECTED_ZLIB_CPE = - "cpe:2.3:a:zlib:zlib:1.3.2.1-motley-3246f1b:*:*:*:*:*:*:*"; + "cpe:2.3:a:zlib:zlib:1.3.2.1-motley-8002e91:*:*:*:*:*:*:*"; function isRecord(value) { return Object.prototype.toString.call(value) === "[object Object]"; @@ -208,15 +208,15 @@ function scanMatchesExactComponent(rawComponentScan, component) { * - OpenSSL CVE-2026-14456 requires exact evidence that both QUIC transport * dependencies are disabled in this Node build. * - nghttp2 CVE-2026-58055 describes the nghttpx proxy request-forwarding - * behavior; the exact Node 24.19.0 runtime inventories libnghttp2 as a + * behavior; the exact Node 24.21.0 runtime inventories libnghttp2 as a * statically bundled dependency, not the nghttpx proxy executable. * - legacy V8 advisories are bounded to historical affected V8/Node releases - * that predate the exact Node 24.19.0 / V8 13.6.233.17-node.51 runtime. + * that predate the exact Node 24.21.0 / V8 13.6.233.17-node.53 runtime. * - CVE-2026-85046 is tied to V8's inlined Array.prototype.sort reducers. The - * exact signed Node 24.19.0 V8 branch contains neither affected reducer, so + * exact signed Node 24.21.0 V8 branch contains neither affected reducer, so * the generic V8 CPE range is not execution-path evidence for this runtime. * - the three SQLite findings have reviewed vulnerable ranges ending no later - * than 3.50.1, while this exact embedded runtime is SQLite 3.53.3. + * than 3.50.1, while this exact embedded runtime is SQLite 3.53.4. * - GHSA-g857-hhfv-j68w is a RubyGems zlib/GzipReader advisory, not an * upstream C zlib-library advisory; the exact Node component is the latter. * diff --git a/scripts/lib/patch-validator-embedded-runtime-inventory.mjs b/scripts/lib/patch-validator-embedded-runtime-inventory.mjs index 283ec0957..061cfba8b 100644 --- a/scripts/lib/patch-validator-embedded-runtime-inventory.mjs +++ b/scripts/lib/patch-validator-embedded-runtime-inventory.mjs @@ -6,7 +6,8 @@ import { } from "./patch-validator-embedded-runtime-catalog.mjs"; const COMPONENT_KEY = /^[a-z0-9][a-z0-9_-]{0,63}$/; -const EXPECTED_NODE_VERSION = "24.19.0"; +const EXPECTED_NODE_VERSION = "24.21.0"; +const EXPECTED_UNDICI_VERSION = "7.29.1"; const VALIDATOR_IMAGE_DIGEST = /^sha256:[0-9a-f]{64}$/; function isRecord(value) { @@ -37,6 +38,11 @@ export function generateEmbeddedRuntimeInventory(versions, validatorImageDigest) if (versions.node !== EXPECTED_NODE_VERSION) { throw new Error("process.versions Node version does not match the reviewed runtime"); } + if (versions.undici !== EXPECTED_UNDICI_VERSION) { + throw new Error( + `process.versions undici must equal reviewed fixed version ${EXPECTED_UNDICI_VERSION}`, + ); + } const components = []; const scanPlan = []; diff --git a/scripts/lib/patch-validator-static-runtime-evidence.mjs b/scripts/lib/patch-validator-static-runtime-evidence.mjs index 513d07d72..5372052cf 100644 --- a/scripts/lib/patch-validator-static-runtime-evidence.mjs +++ b/scripts/lib/patch-validator-static-runtime-evidence.mjs @@ -11,7 +11,8 @@ const RFC3339_TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-](\d{2}):(\d{2}))$/; const GRYPE_DATABASE_SCHEMA = /^v\d+\.\d+\.\d+$/; const PROVIDER_NAME = /^[a-z0-9][a-z0-9_.-]{0,63}$/; -const EXPECTED_NODE_VERSION = "24.19.0"; +const EXPECTED_NODE_VERSION = "24.21.0"; +const EXPECTED_UNDICI_VERSION = "7.29.1"; const EXPECTED_NODE_CPE = `cpe:2.3:a:nodejs:node.js:${EXPECTED_NODE_VERSION}:*:*:*:*:*:*:*`; const EMBEDDED_INVENTORY_SCHEMA = @@ -479,6 +480,10 @@ function verifyEmbeddedRuntimeEvidence({ expectedKeys.length > 0 && expectedKeys.length <= EMBEDDED_COMPONENT_LIMIT, "embedded runtime process.versions dependencies must be a bounded non-empty set", ); + requireCondition( + processVersions.undici === EXPECTED_UNDICI_VERSION, + `embedded runtime process.versions undici must equal reviewed fixed version ${EXPECTED_UNDICI_VERSION}`, + ); const componentByKey = new Map(); const scannableComponentByKey = new Map(); diff --git a/scripts/verify-orchestrator-gateway.mjs b/scripts/verify-orchestrator-gateway.mjs index 09efd05ae..d16b05542 100644 --- a/scripts/verify-orchestrator-gateway.mjs +++ b/scripts/verify-orchestrator-gateway.mjs @@ -4,7 +4,8 @@ import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { defaultOrchestratorModel, - parseOrchestratorGatewayUrl, + parseOrchestratorGatewayApiUrlAllowlist, + requireAllowedOrchestratorGatewayUrl, resolveOrchestratorModel, serializeOrchestratorGatewayConsumerContract, verifyOrchestratorHealthz, @@ -126,8 +127,12 @@ export async function runVerifyOrchestratorGatewayCli(input) { const configuredModel = String(input.env?.NOEMA_LLM_MODEL ?? "").trim(); const model = resolveOrchestratorModel(configuredModel || defaultOrchestratorModel()); - const gateway = parseOrchestratorGatewayUrl( - String(input.env?.NOEMA_LLM_API_URL ?? "").trim(), + const allowedApiUrls = parseOrchestratorGatewayApiUrlAllowlist( + String(input.env?.NOEMA_LLM_API_URL_ALLOWLIST_JSON ?? ""), + ); + const gateway = requireAllowedOrchestratorGatewayUrl( + String(input.env?.NOEMA_LLM_API_URL ?? ""), + allowedApiUrls, ); await verifyOrchestratorHealthz(gateway.healthzUrl, { fetchImpl: input.fetchImpl, @@ -136,6 +141,7 @@ export async function runVerifyOrchestratorGatewayCli(input) { if (options.openCodeConfigPath) { writeOpenCodeOrchestratorConfig(options.openCodeConfigPath, { apiUrl: gateway.href, + allowedApiUrls, model, }); } @@ -198,6 +204,8 @@ export function createVerifyOrchestratorGatewayProcessCli(processLike = process) const processEnv = processLike.env ?? {}; const preflightEnv = { NOEMA_LLM_API_URL: processEnv.NOEMA_LLM_API_URL, + NOEMA_LLM_API_URL_ALLOWLIST_JSON: + processEnv.NOEMA_LLM_API_URL_ALLOWLIST_JSON, NOEMA_LLM_MODEL: processEnv.NOEMA_LLM_MODEL, GITHUB_EVENT_PATH: processEnv.GITHUB_EVENT_PATH, }; diff --git a/test/acquisition-readiness-scan-toolchain-integrity.test.ts b/test/acquisition-readiness-scan-toolchain-integrity.test.ts index 48c89b1e4..6f148cab5 100644 --- a/test/acquisition-readiness-scan-toolchain-integrity.test.ts +++ b/test/acquisition-readiness-scan-toolchain-integrity.test.ts @@ -24,9 +24,9 @@ describe("acquisition-readiness workflow supply-chain integrity", () => { }); it("uses exact Node/npm identities without installing dependency code", () => { - expect(workflow).toContain('node-version: "24.19.0"'); - expect(workflow).toContain('test "$(node --version)" = "v24.19.0"'); - expect(workflow).toContain('test "$(npm --version)" = "11.17.0"'); + expect(workflow).toContain('node-version: "24.21.0"'); + expect(workflow).toContain('test "$(node --version)" = "v24.21.0"'); + expect(workflow).toContain('test "$(npm --version)" = "11.19.0"'); expect(workflow).not.toContain('node-version: "24"'); expect(workflow).not.toMatch(/\bnpm\s+(?:ci|install|i)\b/); expect(workflow).not.toContain(" - name: install"); diff --git a/test/cd-toolchain-integrity.test.ts b/test/cd-toolchain-integrity.test.ts index ebeffa704..6d7715ddd 100644 --- a/test/cd-toolchain-integrity.test.ts +++ b/test/cd-toolchain-integrity.test.ts @@ -8,11 +8,11 @@ describe("production deployment toolchain integrity", () => { expect(workflow).toContain( "uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0", ); - expect(workflow).toContain('node-version: "24.19.0"'); - expect(workflow).toContain('test "$(node --version)" = "v24.19.0"'); - expect(workflow).toContain('test "$(npm --version)" = "11.17.0"'); + expect(workflow).toContain('node-version: "24.21.0"'); + expect(workflow).toContain('test "$(node --version)" = "v24.21.0"'); + expect(workflow).toContain('test "$(npm --version)" = "11.19.0"'); - const toolchainIndex = workflow.indexOf('test "$(npm --version)" = "11.17.0"'); + const toolchainIndex = workflow.indexOf('test "$(npm --version)" = "11.19.0"'); const installIndex = workflow.indexOf( "npm ci --legacy-peer-deps=false --install-links=false", ); diff --git a/test/ci-exact-head-contract.test.ts b/test/ci-exact-head-contract.test.ts index 82a00e724..9b486caf0 100644 --- a/test/ci-exact-head-contract.test.ts +++ b/test/ci-exact-head-contract.test.ts @@ -37,9 +37,9 @@ function expectExactHeadContract(workflow: string, firstExecutionStep: string): /** Assert that application CI uses the reviewed Node/npm identity and explicit tree-shaping flags. */ function expectPinnedApplicationToolchain(workflow: string): void { - expect(workflow).toContain('node-version: "24.19.0"'); - expect(workflow).toContain('test "$(node --version)" = "v24.19.0"'); - expect(workflow).toContain('test "$(npm --version)" = "11.17.0"'); + expect(workflow).toContain('node-version: "24.21.0"'); + expect(workflow).toContain('test "$(node --version)" = "v24.21.0"'); + expect(workflow).toContain('test "$(npm --version)" = "11.19.0"'); expect(workflow).toContain("npm ci --legacy-peer-deps=false --install-links=false"); const exactHeadGate = workflow.indexOf("- name: verify exact checkout"); diff --git a/test/codegraph-experimental-warning.test.ts b/test/codegraph-experimental-warning.test.ts index d4b1156d7..b1776bb4a 100644 --- a/test/codegraph-experimental-warning.test.ts +++ b/test/codegraph-experimental-warning.test.ts @@ -12,7 +12,7 @@ const RUNNER_PATHS = [ describe("CodeGraph SQLite warning boundary", () => { it("proves the canonical hosted Node line loads node:sqlite without ExperimentalWarning", () => { - expect(PACKAGE.devEngines?.runtime?.version).toBe("24.19.0"); + expect(PACKAGE.devEngines?.runtime?.version).toBe("24.21.0"); const probe = spawnSync( process.execPath, diff --git a/test/fixtures/orchestrator-gateway-endpoint-allowlist-v2.json b/test/fixtures/orchestrator-gateway-endpoint-allowlist-v2.json new file mode 100644 index 000000000..c1428cff3 --- /dev/null +++ b/test/fixtures/orchestrator-gateway-endpoint-allowlist-v2.json @@ -0,0 +1,11 @@ +{ + "allowed": [ + "https://orchestrator-a.example/v1", + "https://orchestrator-b.example/inference/v1" + ], + "rejected": [ + "https://attacker.example/v1", + "http://127.0.0.1:18080/v1", + "https://api.openai.com/v1" + ] +} diff --git a/test/hourly-commercial-readiness-toolchain-integrity.test.ts b/test/hourly-commercial-readiness-toolchain-integrity.test.ts index 7fcdfccce..c85467723 100644 --- a/test/hourly-commercial-readiness-toolchain-integrity.test.ts +++ b/test/hourly-commercial-readiness-toolchain-integrity.test.ts @@ -45,11 +45,11 @@ describe("commercial writer toolchain integrity", () => { expect(setup.block).toContain( "uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0", ); - expect(setup.block).toContain('node-version: "24.19.0"'); + expect(setup.block).toContain('node-version: "24.21.0"'); expect(setup.block).not.toContain('node-version: "24"'); - expect(verify.block).toContain('test "$(node --version)" = "v24.19.0"'); - expect(verify.block).toContain('test "$(npm --version)" = "11.17.0"'); + expect(verify.block).toContain('test "$(node --version)" = "v24.21.0"'); + expect(verify.block).toContain('test "$(npm --version)" = "11.19.0"'); expect(install.block).toContain( "run: npm ci --legacy-peer-deps=false --install-links=false", diff --git a/test/hourly-product-development-publication-prerequisite.test.ts b/test/hourly-product-development-publication-prerequisite.test.ts index 5954014ff..b5646e7de 100644 --- a/test/hourly-product-development-publication-prerequisite.test.ts +++ b/test/hourly-product-development-publication-prerequisite.test.ts @@ -62,6 +62,18 @@ describe("hourly product-development publication prerequisites", () => { ]) { expect(operations).toContain(requiredText); } + expect(operations).toContain( + "NOEMA_LLM_API_URL_ALLOWLIST_JSON`이 없으면 `orchestrator_gateway_unavailable`로 종료합니다.", + ); + expect(operations).toContain( + "선택 URL이 exact member가 아니면 gateway preflight 단계가 health 요청과 credential-bearing 설정 전에 실패합니다.", + ); + expect(operations).not.toContain( + "없거나 선택 URL이 exact member가 아니면 `orchestrator_gateway_unavailable`", + ); + expect(operations).toContain( + "`orchestrator_gateway_unavailable`이면 게이트웨이 URL, `NOEMA_LLM_API_URL_ALLOWLIST_JSON`, 전용 추론 토큰, `/healthz` 신원을 복구합니다.", + ); expect(doctoring).toContain("NIST SP 800-218"); expect(doctoring).toContain("APA 7"); expect(doctoring).toContain("least privilege"); diff --git a/test/hourly-product-development-workflow.test.ts b/test/hourly-product-development-workflow.test.ts index 7720912d6..1236074dc 100644 --- a/test/hourly-product-development-workflow.test.ts +++ b/test/hourly-product-development-workflow.test.ts @@ -140,6 +140,9 @@ describe("centrally dispatched contextual-orchestrator product-development workf expect(workflow).toContain( "ORCHESTRATOR_URL_CONFIGURED: ${{ vars.NOEMA_LLM_API_URL != '' }}", ); + expect(workflow).toContain( + "ORCHESTRATOR_ALLOWLIST_CONFIGURED: ${{ vars.NOEMA_LLM_API_URL_ALLOWLIST_JSON != '' }}", + ); expect(workflow).toContain("dispatch=false"); expect(workflow).toContain("dispatch=true"); expect(workflow).not.toContain("nim_api_key_unavailable"); @@ -156,6 +159,9 @@ describe("centrally dispatched contextual-orchestrator product-development workf expect(workflow).toContain( "NOEMA_LLM_API_URL: ${{ vars.NOEMA_LLM_API_URL }}", ); + expect(workflow).toContain( + "NOEMA_LLM_API_URL_ALLOWLIST_JSON: ${{ vars.NOEMA_LLM_API_URL_ALLOWLIST_JSON }}", + ); expect(workflow).toContain("NOEMA_LLM_MODEL: orchestrator/free"); expect(workflow).not.toContain("NOEMA_LLM_MODEL: ${{ vars.NOEMA_LLM_MODEL }}"); expect(workflow).toContain("node scripts/verify-orchestrator-gateway.mjs"); diff --git a/test/maintainer-app-readiness-toolchain-integrity.test.ts b/test/maintainer-app-readiness-toolchain-integrity.test.ts index f3e2d81aa..3900a31b6 100644 --- a/test/maintainer-app-readiness-toolchain-integrity.test.ts +++ b/test/maintainer-app-readiness-toolchain-integrity.test.ts @@ -46,9 +46,9 @@ describe("maintainer App readiness runtime integrity", () => { expect(setup.block).toContain( "uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0", ); - expect(setup.block).toContain('node-version: "24.19.0"'); + expect(setup.block).toContain('node-version: "24.21.0"'); expect(setup.block).not.toContain('node-version: "24"'); - expect(verify.block).toContain('test "$(node --version)" = "v24.19.0"'); + expect(verify.block).toContain('test "$(node --version)" = "v24.21.0"'); expect(setup.index).toBeLessThan(verify.index); expect(verify.index).toBeLessThan(governance.index); diff --git a/test/no-heuristic-gateway-workflow.test.ts b/test/no-heuristic-gateway-workflow.test.ts index 9094af35d..e57481e0b 100644 --- a/test/no-heuristic-gateway-workflow.test.ts +++ b/test/no-heuristic-gateway-workflow.test.ts @@ -12,6 +12,9 @@ describe("Noema gateway workflows have no local provider-routing authority", () const reviewer = "python -m noema_reviewer"; expect(publication).toContain(`NOEMA_LLM_MODEL: ${FREE_POOL}`); + expect(publication).toContain( + "NOEMA_LLM_API_URL_ALLOWLIST_JSON: ${{ vars.NOEMA_LLM_API_URL_ALLOWLIST_JSON }}", + ); expect(publication).not.toContain("NOEMA_LLM_MODEL: ${{ vars.NOEMA_LLM_MODEL }}"); expect(publication).not.toContain("NOEMA_LLM_REQUEST_TIMEOUT_SECONDS"); expect(publication).not.toContain("NOEMA_LLM_MAX_RETRIES"); @@ -42,6 +45,9 @@ describe("Noema gateway workflows have no local provider-routing authority", () ); expect(proposer).toContain(`NOEMA_LLM_MODEL: ${FREE_POOL}`); + expect(proposer).toContain( + "NOEMA_LLM_API_URL_ALLOWLIST_JSON: ${{ vars.NOEMA_LLM_API_URL_ALLOWLIST_JSON }}", + ); expect(proposer).not.toContain("vars.NOEMA_LLM_MODEL"); expect(proposer).not.toContain("OPENCODE_RUN_TIMEOUT_SECONDS"); expect(proposer).not.toContain("OPENCODE_KILL_GRACE_SECONDS"); diff --git a/test/opencode-tool-capability-boundary.test.ts b/test/opencode-tool-capability-boundary.test.ts index 1db60a391..d72141bf1 100644 --- a/test/opencode-tool-capability-boundary.test.ts +++ b/test/opencode-tool-capability-boundary.test.ts @@ -6,6 +6,7 @@ describe("OpenCode tool capability boundary", () => { it("denies unknown tools by default and allows only worktree analysis/edit capabilities", () => { const config = buildOpenCodeOrchestratorConfig({ apiUrl: "https://orchestrator.example/v1", + allowedApiUrls: ["https://orchestrator.example/v1"], model: "orchestrator/free", }); diff --git a/test/orchestrator-gateway-cli-preflight-timeout.test.ts b/test/orchestrator-gateway-cli-preflight-timeout.test.ts index 11df96888..ab987c938 100644 --- a/test/orchestrator-gateway-cli-preflight-timeout.test.ts +++ b/test/orchestrator-gateway-cli-preflight-timeout.test.ts @@ -17,6 +17,7 @@ describe("contextual-orchestrator CLI health preflight", () => { argv: [], env: { NOEMA_LLM_API_URL: "https://orchestrator.example/v1", + NOEMA_LLM_API_URL_ALLOWLIST_JSON: '["https://orchestrator.example/v1"]', NOEMA_LLM_MODEL: "contextual-orchestrator", }, fetchImpl, @@ -40,6 +41,7 @@ describe("contextual-orchestrator CLI health preflight", () => { argv: [], env: { NOEMA_LLM_API_URL: "https://orchestrator.example/v1", + NOEMA_LLM_API_URL_ALLOWLIST_JSON: '["https://orchestrator.example/v1"]', NOEMA_LLM_MODEL: "orchestrator/free", }, fetchImpl: ((_: unknown, init?: RequestInit) => { diff --git a/test/orchestrator-gateway-contract.test.ts b/test/orchestrator-gateway-contract.test.ts index 582f1b3dc..b6148ceaa 100644 --- a/test/orchestrator-gateway-contract.test.ts +++ b/test/orchestrator-gateway-contract.test.ts @@ -31,6 +31,9 @@ import { writeVerifyOrchestratorGatewayStdout, } from "../scripts/verify-orchestrator-gateway.mjs"; +const GATEWAY_ALLOWLIST_JSON = '["https://orchestrator.example/v1"]'; +const GATEWAY_ALLOWLIST = ["https://orchestrator.example/v1"]; + const temporaryDirectories: string[] = []; afterEach(() => { @@ -136,6 +139,7 @@ describe("contextual-orchestrator gateway contract", () => { it("writes a single-provider OpenCode config that never embeds the API key", () => { const config = buildOpenCodeOrchestratorConfig({ apiUrl: "https://orchestrator.example/v1", + allowedApiUrls: GATEWAY_ALLOWLIST, model: defaultOrchestratorModel(), }); const serialized = JSON.stringify(config); @@ -157,6 +161,7 @@ describe("contextual-orchestrator gateway contract", () => { const output = join(tempDir(), "opencode.json"); writeOpenCodeOrchestratorConfig(output, { apiUrl: "https://orchestrator.example/v1", + allowedApiUrls: GATEWAY_ALLOWLIST, model: defaultOrchestratorModel(), }); expect(readFileSync(output, "utf8")).toContain("orchestrator/free"); @@ -166,6 +171,7 @@ describe("contextual-orchestrator gateway contract", () => { const healthy = await verifyOrchestratorGatewayContract({ env: { NOEMA_LLM_API_URL: "https://orchestrator.example/v1", + NOEMA_LLM_API_URL_ALLOWLIST_JSON: GATEWAY_ALLOWLIST_JSON, NOEMA_LLM_MODEL: "orchestrator/free", }, fetchImpl: async () => jsonResponse( @@ -178,6 +184,7 @@ describe("contextual-orchestrator gateway contract", () => { await expect(verifyOrchestratorGatewayContract({ env: { NOEMA_LLM_API_URL: "https://orchestrator.example/v1", + NOEMA_LLM_API_URL_ALLOWLIST_JSON: GATEWAY_ALLOWLIST_JSON, }, fetchImpl: async () => jsonResponse( JSON.stringify({ status: "ok", service: "openai" }), @@ -188,16 +195,29 @@ describe("contextual-orchestrator gateway contract", () => { await expect(verifyOrchestratorGatewayContract({ env: { NOEMA_LLM_API_URL: "https://api.openai.com/v1", + NOEMA_LLM_API_URL_ALLOWLIST_JSON: '["https://api.openai.com/v1"]', }, fetchImpl: async () => { throw new Error("fetch must not run for a direct provider"); }, })).rejects.toThrow(/not a direct model provider/); + await expect(verifyOrchestratorGatewayContract({ + env: { + NOEMA_LLM_API_URL: "https://orchestrator.example/v1/", + NOEMA_LLM_API_URL_ALLOWLIST_JSON: GATEWAY_ALLOWLIST_JSON, + }, + fetchImpl: async () => jsonResponse( + JSON.stringify({ status: "ok", service: "contextual-orchestrator" }), + { status: 200 }, + ), + })).rejects.toThrow(/canonical endpoint URL/); + const written = join(tempDir(), "from-verify.json"); const verifiedWrite = await verifyOrchestratorGatewayContract({ env: { - NOEMA_LLM_API_URL: "https://orchestrator.example/v1/", + NOEMA_LLM_API_URL: "https://orchestrator.example/v1", + NOEMA_LLM_API_URL_ALLOWLIST_JSON: GATEWAY_ALLOWLIST_JSON, }, fetchImpl: async () => jsonResponse( JSON.stringify({ status: "ok", service: "contextual-orchestrator" }), @@ -253,7 +273,7 @@ describe("contextual-orchestrator gateway contract", () => { JSON.stringify({ status: "ok", service: "contextual-orchestrator" }), { status: 200 }, ), - })).rejects.toThrow(/absolute HTTPS URL/); + })).rejects.toThrow(/NOEMA_LLM_API_URL_ALLOWLIST_JSON/); }); it("keeps the CLI fail-closed without requiring secret access", async () => { @@ -285,12 +305,35 @@ describe("contextual-orchestrator gateway contract", () => { }, }); expect(missingUrl).toBe(1); - expect(stderr.join("")).toMatch(/absolute HTTPS URL/); + expect(stderr.join("")).toMatch(/NOEMA_LLM_API_URL_ALLOWLIST_JSON/); + + const missingSelectedUrlStderr: string[] = []; + const missingSelectedUrl = await runVerifyOrchestratorGatewayCli({ + argv: [], + env: { + NOEMA_LLM_API_URL_ALLOWLIST_JSON: GATEWAY_ALLOWLIST_JSON, + }, + fetchImpl: async () => { + throw new Error("fetch must not run without a selected endpoint"); + }, + writeStdout: (message) => { + stdout.push(message); + }, + writeStderr: (message) => { + missingSelectedUrlStderr.push(message); + }, + }); + expect(missingSelectedUrl).toBe(1); + expect(missingSelectedUrlStderr.join("")).toMatch( + /NOEMA_LLM_API_URL must be an absolute HTTPS URL/, + ); const directProvider = await runVerifyOrchestratorGatewayCli({ argv: [], env: { NOEMA_LLM_API_URL: "https://integrate.api.nvidia.com/v1", + NOEMA_LLM_API_URL_ALLOWLIST_JSON: + '["https://integrate.api.nvidia.com/v1"]', }, writeStdout: (message) => { stdout.push(message); @@ -336,6 +379,11 @@ describe("contextual-orchestrator gateway contract", () => { expect(narrative).toContain("naruon is a first-class consumer"); expect(narrative).toContain("separate repository pull request"); expect(narrative).toContain("Do not clone an OpenCode sidecar"); + expect(narrative).toContain("parseOrchestratorGatewayApiUrlAllowlist"); + expect(narrative).toContain("requireAllowedOrchestratorGatewayUrl"); + expect(narrative).toMatch( + /parseOrchestratorGatewayUrl`\s+alone is not sufficient/, + ); }); it("prints the consumer contract without reading secrets", async () => { @@ -370,6 +418,7 @@ describe("contextual-orchestrator gateway contract", () => { env: { GITHUB_EVENT_PATH: publicRepositoryEventFile(), NOEMA_LLM_API_URL: "https://orchestrator.example/v1", + NOEMA_LLM_API_URL_ALLOWLIST_JSON: GATEWAY_ALLOWLIST_JSON, NOEMA_LLM_MODEL: "orchestrator/free", }, fetchImpl: async () => jsonResponse( @@ -390,6 +439,7 @@ describe("contextual-orchestrator gateway contract", () => { argv: [], env: { NOEMA_LLM_API_URL: "https://orchestrator.example/v1", + NOEMA_LLM_API_URL_ALLOWLIST_JSON: GATEWAY_ALLOWLIST_JSON, }, fetchImpl: async () => { throw "boom"; @@ -403,6 +453,7 @@ describe("contextual-orchestrator gateway contract", () => { argv: [], env: { NOEMA_LLM_API_URL: "https://orchestrator.example/v1", + NOEMA_LLM_API_URL_ALLOWLIST_JSON: GATEWAY_ALLOWLIST_JSON, }, fetchImpl: async () => jsonResponse( JSON.stringify({ status: "ok", service: "contextual-orchestrator" }), @@ -435,6 +486,7 @@ describe("contextual-orchestrator gateway contract", () => { env: { PATH: process.env.PATH, NOEMA_LLM_API_URL: "https://api.openai.com/v1", + NOEMA_LLM_API_URL_ALLOWLIST_JSON: '["https://api.openai.com/v1"]', }, }, ); @@ -454,6 +506,7 @@ describe("contextual-orchestrator gateway contract", () => { argv: [process.execPath, "scripts/verify-orchestrator-gateway.mjs"], env: { NOEMA_LLM_API_URL: "https://orchestrator.example/v1", + NOEMA_LLM_API_URL_ALLOWLIST_JSON: GATEWAY_ALLOWLIST_JSON, }, fetchImpl: async () => jsonResponse( JSON.stringify({ status: "ok", service: "contextual-orchestrator" }), @@ -470,7 +523,9 @@ describe("contextual-orchestrator gateway contract", () => { }, }); expect(await emptyProcessCli()).toBe(1); - expect(emptyProcessStderr.join("")).toMatch(/absolute HTTPS URL/); + expect(emptyProcessStderr.join("")).toMatch( + /NOEMA_LLM_API_URL_ALLOWLIST_JSON/, + ); process.exitCode = previousProcessExit; }); -}); \ No newline at end of file +}); diff --git a/test/orchestrator-gateway-endpoint-allowlist.test.ts b/test/orchestrator-gateway-endpoint-allowlist.test.ts new file mode 100644 index 000000000..632d0d317 --- /dev/null +++ b/test/orchestrator-gateway-endpoint-allowlist.test.ts @@ -0,0 +1,151 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it, vi } from "vitest"; + +import { + buildOpenCodeOrchestratorConfig, + parseOrchestratorGatewayApiUrlAllowlist, + verifyOrchestratorGatewayContract, +} from "../scripts/lib/orchestrator-gateway.mjs"; + +const fixture = JSON.parse( + readFileSync( + "test/fixtures/orchestrator-gateway-endpoint-allowlist-v2.json", + "utf8", + ), +) as { allowed: string[]; rejected: string[] }; +const allowlistJson = JSON.stringify(fixture.allowed); + +describe("contextual-orchestrator released endpoint admission", () => { + it("rejects a self-identified arbitrary HTTPS host before health fetch", async () => { + const fetchImpl = vi.fn(async () => new Response( + JSON.stringify({ status: "ok", service: "contextual-orchestrator" }), + { status: 200, headers: { "content-type": "application/json" } }, + )); + + await expect(verifyOrchestratorGatewayContract({ + env: { + NOEMA_LLM_API_URL: "https://attacker.example/v1", + NOEMA_LLM_API_URL_ALLOWLIST_JSON: allowlistJson, + }, + fetchImpl, + })).rejects.toThrow(/NOEMA_LLM_API_URL is not an allowed released contextual-orchestrator endpoint/); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("accepts exact membership without assigning list order routing meaning", async () => { + const result = await verifyOrchestratorGatewayContract({ + env: { + NOEMA_LLM_API_URL: fixture.allowed[1], + NOEMA_LLM_API_URL_ALLOWLIST_JSON: allowlistJson, + }, + fetchImpl: async () => new Response( + JSON.stringify({ status: "ok", service: "contextual-orchestrator" }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + }); + + expect(result.apiUrl).toBe(fixture.allowed[1]); + }); + + it("does not fall back across allowlist members after selected health failure", async () => { + const requestedUrls: string[] = []; + await expect(verifyOrchestratorGatewayContract({ + env: { + NOEMA_LLM_API_URL: fixture.allowed[1], + NOEMA_LLM_API_URL_ALLOWLIST_JSON: allowlistJson, + }, + fetchImpl: async (input) => { + requestedUrls.push(String(input)); + return new Response("unavailable", { status: 503 }); + }, + })).rejects.toThrow(/status is 503/); + + expect(requestedUrls).toEqual([ + "https://orchestrator-b.example/inference/healthz", + ]); + }); + + it.each([ + undefined, + null, + "", + "{}", + "[]", + '["https://orchestrator-a.example/v1", 1]', + '["https://orchestrator-a.example/v1", "https://orchestrator-a.example/v1"]', + '["https://*.example/v1"]', + '["https://ORCHESTRATOR-A.example/v1"]', + '["https://orchestrator-a.example/v1/"]', + '["https://orchestrator-a.example:443/v1"]', + '["https://orchestrator-a.example:99999/v1"]', + '["https://éxample.example/v1"]', + '[" https://orchestrator-a.example/v1 "]', + '["https://orchestrator-a.example./v1"]', + '["http://127.0.0.1:18080/v1"]', + '["https://127.0.0.2/v1"]', + '["https://127.1/v1"]', + '["https://2130706433/v1"]', + '["https://0x7f000001/v1"]', + '["https://0177.0.0.1/v1"]', + '["https://[0:0:0:0:0:0:0:1]/v1"]', + '["https://localhost/v1"]', + '["https://[2001:0db8:0:0:0:0:0:1]/v1"]', + '["https://orchestrator-a.example/./v1"]', + '["https://orchestrator-a.example/x/../v1"]', + '["https://api.openai.com/v1"]', + '["https://%61pi.openai.com/v1"]', + '["https://api%2eopenai.com/v1"]', + '["https://[fe80::1%25eth0]/v1"]', + '["https://orchestrator-a.example/a\\\\b/v1"]', + '["https://orchestrator-a.example\\\\evil.example/v1"]', + '["https://foo..example/v1"]', + `["https://${"a".repeat(64)}.example/v1"]`, + '["https://[::ffff:192.0.2.1]/v1"]', + '["https://[::ffff:c000:201]/v1"]', + ])("rejects malformed or non-authoritative allowlist %j", (raw) => { + expect(() => parseOrchestratorGatewayApiUrlAllowlist(raw)).toThrow( + /NOEMA_LLM_API_URL_ALLOWLIST_JSON/, + ); + }); + + it.each([ + { apiUrl: "https://ORCHESTRATOR-A.example/v1", error: /canonical endpoint URL/ }, + { apiUrl: "https://orchestrator-a.example/v1/", error: /canonical endpoint URL/ }, + { apiUrl: "https://orchestrator-a.example:443/v1", error: /canonical endpoint URL/ }, + { apiUrl: "https://éxample.example/v1", error: /canonical endpoint URL/ }, + { apiUrl: " https://orchestrator-a.example/v1 ", error: /canonical endpoint URL/ }, + { apiUrl: "https://orchestrator-a.example./v1", error: /canonical endpoint URL/ }, + { apiUrl: "https://[2001:0db8:0:0:0:0:0:1]/v1", error: /canonical endpoint URL/ }, + { apiUrl: "https://orchestrator-a.example/./v1", error: /canonical endpoint URL/ }, + { apiUrl: "https://orchestrator-a.example/x/../v1", error: /canonical endpoint URL/ }, + { apiUrl: "https://127.1/v1", error: /loopback endpoint/ }, + { apiUrl: "https://2130706433/v1", error: /loopback endpoint/ }, + { apiUrl: "https://0x7f000001/v1", error: /loopback endpoint/ }, + { apiUrl: "https://0177.0.0.1/v1", error: /loopback endpoint/ }, + { apiUrl: "https://%61pi.openai.com/v1", error: /direct model provider/ }, + { apiUrl: "https://api%2eopenai.com/v1", error: /direct model provider/ }, + { apiUrl: "https://[fe80::1%25eth0]/v1", error: /absolute HTTPS URL/ }, + { apiUrl: "https://orchestrator-a.example/a\\b/v1", error: /canonical endpoint URL/ }, + { apiUrl: "https://orchestrator-a.example\\evil.example/v1", error: /canonical endpoint URL/ }, + { apiUrl: "https://foo..example/v1", error: /canonical endpoint URL/ }, + { apiUrl: `https://${"a".repeat(64)}.example/v1`, error: /canonical endpoint URL/ }, + { apiUrl: "https://[::ffff:192.0.2.1]/v1", error: /IPv4-mapped IPv6 endpoint/ }, + { apiUrl: "https://[::ffff:c000:201]/v1", error: /IPv4-mapped IPv6 endpoint/ }, + ])("rejects a non-canonical selected endpoint $apiUrl", async ({ apiUrl, error }) => { + await expect(verifyOrchestratorGatewayContract({ + env: { + NOEMA_LLM_API_URL: apiUrl, + NOEMA_LLM_API_URL_ALLOWLIST_JSON: allowlistJson, + }, + fetchImpl: vi.fn(), + })).rejects.toThrow(error); + }); + + it("refuses token-bearing OpenCode config without exact membership", () => { + expect(() => buildOpenCodeOrchestratorConfig({ + apiUrl: "https://attacker.example/v1", + allowedApiUrls: fixture.allowed, + model: "orchestrator/free", + })).toThrow(/not an allowed released contextual-orchestrator endpoint/); + }); +}); diff --git a/test/orchestrator-gateway-process-cli-stdio.test.ts b/test/orchestrator-gateway-process-cli-stdio.test.ts index 9230afa0a..2511881e1 100644 --- a/test/orchestrator-gateway-process-cli-stdio.test.ts +++ b/test/orchestrator-gateway-process-cli-stdio.test.ts @@ -24,7 +24,7 @@ describe("contextual-orchestrator process CLI stdio boundary", () => { expect(await cli()).toBe(1); expect(stdout).toEqual([]); expect(stderr.join("")).toMatch( - /Noema contextual-orchestrator preflight failed: NOEMA_LLM_API_URL/, + /Noema contextual-orchestrator preflight failed: NOEMA_LLM_API_URL_ALLOWLIST_JSON/, ); }); }); diff --git a/test/orchestrator-gateway-routing-alias.test.ts b/test/orchestrator-gateway-routing-alias.test.ts index fb9215879..2889b94cc 100644 --- a/test/orchestrator-gateway-routing-alias.test.ts +++ b/test/orchestrator-gateway-routing-alias.test.ts @@ -10,6 +10,7 @@ const routingDoctoring = readFileSync( fileURLToPath(new URL("../docs/doctoring/orchestrator-free-routing-alias.md", import.meta.url)), "utf8", ); +const GATEWAY_ALLOWLIST_JSON = '["https://orchestrator.example/v1"]'; describe("contextual-orchestrator routing alias authority", () => { it("rejects a configurable model override before network access", async () => { @@ -21,6 +22,7 @@ describe("contextual-orchestrator routing alias authority", () => { argv: [], env: { NOEMA_LLM_API_URL: "https://orchestrator.example/v1", + NOEMA_LLM_API_URL_ALLOWLIST_JSON: GATEWAY_ALLOWLIST_JSON, NOEMA_LLM_API_KEY: "gateway-token", NOEMA_LLM_MODEL: "gpt-5", }, @@ -58,6 +60,7 @@ describe("contextual-orchestrator routing alias authority", () => { argv: [], env: { NOEMA_LLM_API_URL: "https://orchestrator.example/v1", + NOEMA_LLM_API_URL_ALLOWLIST_JSON: GATEWAY_ALLOWLIST_JSON, NOEMA_LLM_MODEL: "contextual-orchestrator", }, fetchImpl: async () => { diff --git a/test/orchestrator-gateway-secret-source.test.ts b/test/orchestrator-gateway-secret-source.test.ts index 524dfa097..25d2cbb1c 100644 --- a/test/orchestrator-gateway-secret-source.test.ts +++ b/test/orchestrator-gateway-secret-source.test.ts @@ -20,6 +20,7 @@ function healthyResponse(): Response { function envWithoutSecretAccess(): NodeJS.ProcessEnv { const source: NodeJS.ProcessEnv = { NOEMA_LLM_API_URL: "https://orchestrator.example/v1", + NOEMA_LLM_API_URL_ALLOWLIST_JSON: '["https://orchestrator.example/v1"]', NOEMA_LLM_MODEL: "orchestrator/free", NOEMA_LLM_API_KEY: "must-never-be-read-by-preflight", }; diff --git a/test/orchestrator-gateway-trailing-dot-host.test.ts b/test/orchestrator-gateway-trailing-dot-host.test.ts index c1fae80d3..14d36b6a5 100644 --- a/test/orchestrator-gateway-trailing-dot-host.test.ts +++ b/test/orchestrator-gateway-trailing-dot-host.test.ts @@ -8,7 +8,7 @@ describe("contextual-orchestrator direct-provider hostname canonicalization", () it("rejects DNS-root-dot aliases of every forbidden direct provider host", () => { for (const host of directProviderHosts()) { expect(() => parseOrchestratorGatewayUrl(`https://${host}./v1`)).toThrow( - /contextual-orchestrator, not a direct model provider/, + /canonical endpoint URL/, ); } }); @@ -17,7 +17,7 @@ describe("contextual-orchestrator direct-provider hostname canonicalization", () "rejects a root-dot-only hostname before any health request: %s", (url) => { expect(() => parseOrchestratorGatewayUrl(url)).toThrow( - /absolute HTTPS URL/, + /canonical endpoint URL/, ); }, ); diff --git a/test/package-manager-doctoring-contract.test.ts b/test/package-manager-doctoring-contract.test.ts index bfa16c7d7..ad4c8edc9 100644 --- a/test/package-manager-doctoring-contract.test.ts +++ b/test/package-manager-doctoring-contract.test.ts @@ -20,8 +20,8 @@ describe("package-manager reproducibility doctoring", () => { const decision = markdownSection(document, "결정"); const installScripts = markdownSection(document, "Install-script 실행 권한"); - expect(decision).toContain("Node.js 24.19.0"); - expect(decision).toContain("npm 11.17.0"); + expect(decision).toContain("Node.js 24.21.0"); + expect(decision).toContain("npm 11.19.0"); expect(decision).toContain("`.github/workflows/ci.yml`"); expect(installScripts).toContain("`strict-allow-scripts=true`"); @@ -75,7 +75,7 @@ describe("package-manager reproducibility doctoring", () => { expect(changelog).toMatch(/^## Unreleased$/m); const unreleased = markdownSection(changelog, "Unreleased"); - expect(unreleased).toContain("Node.js 24.19.0/npm 11.17.0"); + expect(unreleased).toContain("Node.js 24.21.0/npm 11.19.0"); expect(unreleased).toContain("strict-allow-scripts=true"); expect(unreleased).toContain("schema v3"); }); diff --git a/test/package-manager-predecessor-integration.test.ts b/test/package-manager-predecessor-integration.test.ts index 7e5130e46..71ba94e79 100644 --- a/test/package-manager-predecessor-integration.test.ts +++ b/test/package-manager-predecessor-integration.test.ts @@ -22,15 +22,15 @@ describe("deterministic package-manager work integrated after the nanoid predece integrity: "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", }); - expect(packageJson.packageManager).toBe("npm@11.17.0"); + expect(packageJson.packageManager).toBe("npm@11.19.0"); expect(packageJson.devEngines?.runtime).toEqual({ name: "node", - version: "24.19.0", + version: "24.21.0", onFail: "error", }); expect(packageJson.devEngines?.packageManager).toEqual({ name: "npm", - version: "11.17.0", + version: "11.19.0", onFail: "error", }); }); diff --git a/test/package-manager-reproducibility.test.ts b/test/package-manager-reproducibility.test.ts index fd29ce6f1..6adf778f8 100644 --- a/test/package-manager-reproducibility.test.ts +++ b/test/package-manager-reproducibility.test.ts @@ -52,15 +52,15 @@ function runFixtureNpm( describe("package-manager reproducibility contract", () => { it("pins the reviewed Node and npm identities in repository metadata", () => { - expect(packageJson.packageManager).toBe("npm@11.17.0"); + expect(packageJson.packageManager).toBe("npm@11.19.0"); expect(packageJson.devEngines?.runtime).toEqual({ name: "node", - version: "24.19.0", + version: "24.21.0", onFail: "error", }); expect(packageJson.devEngines?.packageManager).toEqual({ name: "npm", - version: "11.17.0", + version: "11.19.0", onFail: "error", }); }); @@ -144,13 +144,13 @@ describe("package-manager reproducibility contract", () => { }); it("pins CI to the same Node distribution and verifies toolchain identity before install", () => { - expect(ciWorkflow).toContain('node-version: "24.19.0"'); + expect(ciWorkflow).toContain('node-version: "24.21.0"'); const toolchainGate = ciWorkflow.indexOf("name: verify package-manager toolchain"); const install = ciWorkflow.indexOf("name: install"); expect(toolchainGate).toBeGreaterThan(-1); expect(install).toBeGreaterThan(toolchainGate); - expect(ciWorkflow).toContain('test "$(node --version)" = "v24.19.0"'); - expect(ciWorkflow).toContain('test "$(npm --version)" = "11.17.0"'); + expect(ciWorkflow).toContain('test "$(node --version)" = "v24.21.0"'); + expect(ciWorkflow).toContain('test "$(npm --version)" = "11.19.0"'); }); it("checks out and verifies the exact pull-request head instead of GitHub's synthetic merge ref", () => { diff --git a/test/package-manager-review-contract.test.ts b/test/package-manager-review-contract.test.ts index 9782b0d78..ed6fe8a48 100644 --- a/test/package-manager-review-contract.test.ts +++ b/test/package-manager-review-contract.test.ts @@ -78,7 +78,7 @@ describe("package-manager review contracts", () => { ]); const unreleased = markdownSection(changelog, "## Unreleased"); - expect(unreleased).toContain("Node.js 24.19.0/npm 11.17.0"); + expect(unreleased).toContain("Node.js 24.21.0/npm 11.19.0"); expect(unreleased).toContain("strict-allow-scripts=true"); expect(unreleased).toContain("schema v3"); }); diff --git a/test/patch-validator-binary-grype-database-provenance.test.ts b/test/patch-validator-binary-grype-database-provenance.test.ts index c8d6a8796..450407154 100644 --- a/test/patch-validator-binary-grype-database-provenance.test.ts +++ b/test/patch-validator-binary-grype-database-provenance.test.ts @@ -9,7 +9,7 @@ const repositoryRoot = resolve(import.meta.dirname, ".."); const sourceRevision = "1".repeat(40); const imageDigest = `sha256:${"2".repeat(64)}`; const imageReference = `noema-patch-validator:${sourceRevision}`; -const undiciPurl = "pkg:npm/undici@7.13.0"; +const undiciPurl = "pkg:npm/undici@7.29.1"; const providerDigest = `sha256:${"a".repeat(64)}`; function writeJson(root: string, name: string, value: unknown): string { @@ -116,9 +116,9 @@ describe("patch-validator binary Grype database provenance", () => { artifacts: [ { name: "node", - version: "24.19.0", + version: "24.21.0", locations: [{ path: "/nodejs/bin/node" }], - cpes: ["cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*"], + cpes: ["cpe:2.3:a:nodejs:node.js:24.21.0:*:*:*:*:*:*:*"], }, ], }); @@ -131,13 +131,13 @@ describe("patch-validator binary Grype database provenance", () => { const embeddedInventory = writeJson(root, "embedded-inventory.json", { schema_version: "noema.patch-validator-embedded-runtime-inventory.v1", validator_image_digest: imageDigest, - node_version: "24.19.0", - process_versions: { node: "24.19.0", undici: "7.13.0" }, + node_version: "24.21.0", + process_versions: { node: "24.21.0", undici: "7.29.1" }, components: [ { key: "undici", name: "undici", - version: "7.13.0", + version: "7.29.1", classification: "bundled_dependency", purl: undiciPurl, }, diff --git a/test/patch-validator-cpe-match-provenance.test.ts b/test/patch-validator-cpe-match-provenance.test.ts index 645bf671c..3db25b940 100644 --- a/test/patch-validator-cpe-match-provenance.test.ts +++ b/test/patch-validator-cpe-match-provenance.test.ts @@ -4,8 +4,9 @@ import { verifyStaticRuntimeBinaryEvidence } from "../scripts/lib/patch-validato const imageDigest = `sha256:${"2".repeat(64)}`; const providerDigest = `sha256:${"a".repeat(64)}`; -const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*"; +const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.21.0:*:*:*:*:*:*:*"; const opensslCpe = "cpe:2.3:a:openssl:openssl:3.5.2:*:*:*:*:*:*:*"; +const undiciPurl = "pkg:npm/undici@7.29.1"; function scannerOutput(matches: unknown[]) { return { @@ -41,7 +42,7 @@ function inputFor(matches: unknown[]) { artifacts: [ { name: "node", - version: "24.19.0", + version: "24.21.0", locations: [{ path: "/nodejs/bin/node" }], cpes: [nodeCpe], }, @@ -56,8 +57,12 @@ function inputFor(matches: unknown[]) { embeddedRuntimeInventory: { schema_version: "noema.patch-validator-embedded-runtime-inventory.v1", validator_image_digest: imageDigest, - node_version: "24.19.0", - process_versions: { node: "24.19.0", openssl: "3.5.2" }, + node_version: "24.21.0", + process_versions: { + node: "24.21.0", + openssl: "3.5.2", + undici: "7.29.1", + }, components: [ { key: "openssl", @@ -66,6 +71,13 @@ function inputFor(matches: unknown[]) { classification: "bundled_dependency", cpe: opensslCpe, }, + { + key: "undici", + name: "undici", + version: "7.29.1", + classification: "bundled_dependency", + purl: undiciPurl, + }, ], }, embeddedVulnerabilityScan: { @@ -79,6 +91,14 @@ function inputFor(matches: unknown[]) { identity: opensslCpe, scanner_output: scannerOutput(matches), }, + { + key: "undici", + identity: undiciPurl, + scanner_output: { + ...scannerOutput([]), + source: { type: "purl", target: undiciPurl }, + }, + }, ], }, }; diff --git a/test/patch-validator-embedded-runtime-assessment.test.ts b/test/patch-validator-embedded-runtime-assessment.test.ts index 557299bfa..341204f42 100644 --- a/test/patch-validator-embedded-runtime-assessment.test.ts +++ b/test/patch-validator-embedded-runtime-assessment.test.ts @@ -3,8 +3,34 @@ import { describe, expect, it } from "vitest"; import { verifyStaticRuntimeBinaryEvidence } from "../scripts/lib/patch-validator-static-runtime-evidence.mjs"; const imageDigest = `sha256:${"7".repeat(64)}`; -const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*"; +const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.21.0:*:*:*:*:*:*:*"; const opensslCpe = "cpe:2.3:a:openssl:openssl:3.5.2:*:*:*:*:*:*:*"; +const undiciPurl = "pkg:npm/undici@7.29.1"; + +function cleanUndiciScannerOutput(): any { + return { + descriptor: { + name: "grype", + version: "0.116.1", + db: { + status: { + schemaVersion: "v6.0.2", + built: "2026-08-07T00:00:00Z", + valid: true, + }, + providers: { + nvd: { + captured: "2026-08-06T00:00:00Z", + input: `sha256:${"a".repeat(64)}`, + }, + }, + }, + }, + source: { type: "purl", target: undiciPurl }, + matches: [], + ignoredMatches: [], + }; +} function inputWithUnassessedZeroMatchComponent(): any { return { @@ -15,7 +41,7 @@ function inputWithUnassessedZeroMatchComponent(): any { artifacts: [ { name: "node", - version: "24.19.0", + version: "24.21.0", locations: [{ path: "/nodejs/bin/node" }], cpes: [nodeCpe], }, @@ -30,10 +56,11 @@ function inputWithUnassessedZeroMatchComponent(): any { embeddedRuntimeInventory: { schema_version: "noema.patch-validator-embedded-runtime-inventory.v1", validator_image_digest: imageDigest, - node_version: "24.19.0", + node_version: "24.21.0", process_versions: { - node: "24.19.0", + node: "24.21.0", openssl: "3.5.2", + undici: "7.29.1", }, components: [ { @@ -43,6 +70,13 @@ function inputWithUnassessedZeroMatchComponent(): any { classification: "bundled_dependency", cpe: opensslCpe, }, + { + key: "undici", + name: "undici", + version: "7.29.1", + classification: "bundled_dependency", + purl: undiciPurl, + }, ], }, embeddedVulnerabilityScan: { @@ -56,6 +90,11 @@ function inputWithUnassessedZeroMatchComponent(): any { matches: [], ignoredMatches: [], }, + { + key: "undici", + identity: undiciPurl, + scanner_output: cleanUndiciScannerOutput(), + }, ], ignoredMatches: [], }, diff --git a/test/patch-validator-embedded-runtime-catalog-parity.test.ts b/test/patch-validator-embedded-runtime-catalog-parity.test.ts index 94a0608d7..4d610537e 100644 --- a/test/patch-validator-embedded-runtime-catalog-parity.test.ts +++ b/test/patch-validator-embedded-runtime-catalog-parity.test.ts @@ -39,7 +39,7 @@ describe("patch-validator embedded-runtime reviewed identity catalog parity", () }); it("binds Node's patched V8 runtime to the reviewed upstream V8 CPE version", () => { - const processVersion = "13.6.233.17-node.51"; + const processVersion = "13.6.233.17-node.53"; const expected = "cpe:2.3:a:google:v8:13.6.233.17:*:*:*:*:*:*:*"; const identity = reviewedIdentityFor("v8", processVersion); diff --git a/test/patch-validator-embedded-runtime-digest-binding.test.ts b/test/patch-validator-embedded-runtime-digest-binding.test.ts index 7fdccd15b..dc1d57ac9 100644 --- a/test/patch-validator-embedded-runtime-digest-binding.test.ts +++ b/test/patch-validator-embedded-runtime-digest-binding.test.ts @@ -3,8 +3,9 @@ import { describe, expect, it } from "vitest"; import { generateEmbeddedRuntimeInventory } from "../scripts/lib/patch-validator-embedded-runtime-inventory.mjs"; const versions = { - node: "24.19.0", + node: "24.21.0", acorn: "8.15.0", + undici: "7.29.1", }; const validDigest = `sha256:${"4".repeat(64)}`; diff --git a/test/patch-validator-embedded-runtime-inventory.test.ts b/test/patch-validator-embedded-runtime-inventory.test.ts index 521cfd0d0..2ff2d00c0 100644 --- a/test/patch-validator-embedded-runtime-inventory.test.ts +++ b/test/patch-validator-embedded-runtime-inventory.test.ts @@ -5,10 +5,20 @@ import { generateEmbeddedRuntimeInventory } from "../scripts/lib/patch-validator const imageDigest = `sha256:${"4".repeat(64)}`; describe("patch-validator embedded runtime inventory", () => { + it("rejects the vulnerable Undici bundled by the superseded Node runtime", () => { + expect(() => + generateEmbeddedRuntimeInventory( + { node: "24.21.0", undici: "7.29.0" }, + imageDigest, + ), + ).toThrow(/undici.*7\.29\.1/i); + }); + it("uses reviewed c-ares and Brotli identities and keeps disabled QUIC keys explicit", () => { const { inventory, scanPlan } = generateEmbeddedRuntimeInventory( { - node: "24.19.0", + node: "24.21.0", + undici: "7.29.1", ares: "1.34.6", brotli: "1.2.0", cldr: "48.0", @@ -31,6 +41,10 @@ describe("patch-validator embedded runtime inventory", () => { key: "brotli", identity: "cpe:2.3:a:google:brotli:1.2.0:*:*:*:*:*:*:*", }, + { + key: "undici", + identity: "pkg:npm/undici@7.29.1", + }, ]); expect(inventory.components).toContainEqual({ key: "ngtcp2", @@ -51,7 +65,8 @@ describe("patch-validator embedded runtime inventory", () => { it("keeps Node-internal ncrypto version evidence out of the external vulnerability scan plan", () => { const { inventory, scanPlan } = generateEmbeddedRuntimeInventory( { - node: "24.19.0", + node: "24.21.0", + undici: "7.29.1", ares: "1.34.6", ncrypto: "0.0.1", }, @@ -63,6 +78,10 @@ describe("patch-validator embedded runtime inventory", () => { key: "ares", identity: "cpe:2.3:a:c-ares:c-ares:1.34.6:*:*:*:*:*:*:*", }, + { + key: "undici", + identity: "pkg:npm/undici@7.29.1", + }, ]); expect(inventory.components).toContainEqual({ key: "ncrypto", @@ -76,7 +95,11 @@ describe("patch-validator embedded runtime inventory", () => { it("fails closed on an unreviewed non-empty bundled dependency", () => { expect(() => generateEmbeddedRuntimeInventory( - { node: "24.19.0", unknown_native_dependency: "1.2.3" }, + { + node: "24.21.0", + undici: "7.29.1", + unknown_native_dependency: "1.2.3", + }, imageDigest, ), ).toThrow(/no reviewed vulnerability identity/i); @@ -85,7 +108,7 @@ describe("patch-validator embedded runtime inventory", () => { it("rejects an empty version unless the key is a reviewed disabled feature", () => { expect(() => generateEmbeddedRuntimeInventory( - { node: "24.19.0", openssl: "" }, + { node: "24.21.0", undici: "7.29.1", openssl: "" }, imageDigest, ), ).toThrow(/invalid version/i); diff --git a/test/patch-validator-grype-provider-provenance.test.ts b/test/patch-validator-grype-provider-provenance.test.ts index 32d17a905..f100b5b66 100644 --- a/test/patch-validator-grype-provider-provenance.test.ts +++ b/test/patch-validator-grype-provider-provenance.test.ts @@ -3,8 +3,8 @@ import { describe, expect, it } from "vitest"; import { verifyStaticRuntimeBinaryEvidence } from "../scripts/lib/patch-validator-static-runtime-evidence.mjs"; const imageDigest = `sha256:${"2".repeat(64)}`; -const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*"; -const undiciPurl = "pkg:npm/undici@7.13.0"; +const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.21.0:*:*:*:*:*:*:*"; +const undiciPurl = "pkg:npm/undici@7.29.1"; function inputWithProviderDigest(providerInput: string): any { return { @@ -15,7 +15,7 @@ function inputWithProviderDigest(providerInput: string): any { artifacts: [ { name: "node", - version: "24.19.0", + version: "24.21.0", locations: [{ path: "/nodejs/bin/node" }], cpes: [nodeCpe], }, @@ -30,13 +30,13 @@ function inputWithProviderDigest(providerInput: string): any { embeddedRuntimeInventory: { schema_version: "noema.patch-validator-embedded-runtime-inventory.v1", validator_image_digest: imageDigest, - node_version: "24.19.0", - process_versions: { node: "24.19.0", undici: "7.13.0" }, + node_version: "24.21.0", + process_versions: { node: "24.21.0", undici: "7.29.1" }, components: [ { key: "undici", name: "undici", - version: "7.13.0", + version: "7.29.1", classification: "bundled_dependency", purl: undiciPurl, }, diff --git a/test/patch-validator-grype-timestamp-calendar-integrity.test.ts b/test/patch-validator-grype-timestamp-calendar-integrity.test.ts index 31e13bbd7..ceb1caa6d 100644 --- a/test/patch-validator-grype-timestamp-calendar-integrity.test.ts +++ b/test/patch-validator-grype-timestamp-calendar-integrity.test.ts @@ -4,8 +4,34 @@ import { verifyStaticRuntimeBinaryEvidence } from "../scripts/lib/patch-validato const imageDigest = `sha256:${"8".repeat(64)}`; const providerDigest = `sha256:${"9".repeat(64)}`; -const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*"; +const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.21.0:*:*:*:*:*:*:*"; const opensslCpe = "cpe:2.3:a:openssl:openssl:3.5.2:*:*:*:*:*:*:*"; +const undiciPurl = "pkg:npm/undici@7.29.1"; + +function scannerOutput(identity: string): any { + return { + descriptor: { + name: "grype", + version: "0.116.1", + db: { + status: { + schemaVersion: "v6.0.2", + built: "2026-08-07T00:00:00Z", + valid: true, + }, + providers: { + nvd: { + captured: "2026-08-06T00:00:00Z", + input: providerDigest, + }, + }, + }, + }, + source: { type: identity.startsWith("pkg:") ? "purl" : "cpe", target: identity }, + matches: [], + ignoredMatches: [], + }; +} function validInput(): any { return { @@ -16,7 +42,7 @@ function validInput(): any { artifacts: [ { name: "node", - version: "24.19.0", + version: "24.21.0", locations: [{ path: "/nodejs/bin/node" }], cpes: [nodeCpe], }, @@ -31,8 +57,12 @@ function validInput(): any { embeddedRuntimeInventory: { schema_version: "noema.patch-validator-embedded-runtime-inventory.v1", validator_image_digest: imageDigest, - node_version: "24.19.0", - process_versions: { node: "24.19.0", openssl: "3.5.2" }, + node_version: "24.21.0", + process_versions: { + node: "24.21.0", + openssl: "3.5.2", + undici: "7.29.1", + }, components: [ { key: "openssl", @@ -41,6 +71,13 @@ function validInput(): any { classification: "bundled_dependency", cpe: opensslCpe, }, + { + key: "undici", + name: "undici", + version: "7.29.1", + classification: "bundled_dependency", + purl: undiciPurl, + }, ], }, embeddedVulnerabilityScan: { @@ -51,28 +88,12 @@ function validInput(): any { { key: "openssl", identity: opensslCpe, - scanner_output: { - descriptor: { - name: "grype", - version: "0.116.1", - db: { - status: { - schemaVersion: "v6.0.2", - built: "2026-08-07T00:00:00Z", - valid: true, - }, - providers: { - nvd: { - captured: "2026-08-06T00:00:00Z", - input: providerDigest, - }, - }, - }, - }, - source: { type: "cpe", target: opensslCpe }, - matches: [], - ignoredMatches: [], - }, + scanner_output: scannerOutput(opensslCpe), + }, + { + key: "undici", + identity: undiciPurl, + scanner_output: scannerOutput(undiciPurl), }, ], ignoredMatches: [], @@ -127,6 +148,10 @@ describe("Grype database timestamp calendar integrity", () => { "2026-08-07T09:45:30+09:30"; input.embeddedVulnerabilityScan.components[0].scanner_output.descriptor.db.providers.nvd.captured = "2026-08-06T23:59:59-05:45"; + input.embeddedVulnerabilityScan.components[1].scanner_output.descriptor.db = + structuredClone( + input.embeddedVulnerabilityScan.components[0].scanner_output.descriptor.db, + ); expect(() => verifyStaticRuntimeBinaryEvidence(input)).not.toThrow(); }); @@ -137,6 +162,10 @@ describe("Grype database timestamp calendar integrity", () => { "2000-02-29T00:00:00Z"; input.embeddedVulnerabilityScan.components[0].scanner_output.descriptor.db.providers.nvd.captured = "2000-02-29T00:00:00Z"; + input.embeddedVulnerabilityScan.components[1].scanner_output.descriptor.db = + structuredClone( + input.embeddedVulnerabilityScan.components[0].scanner_output.descriptor.db, + ); expect(() => verifyStaticRuntimeBinaryEvidence(input)).not.toThrow(); }); diff --git a/test/patch-validator-grype-timestamp-future-integrity.test.ts b/test/patch-validator-grype-timestamp-future-integrity.test.ts index fcebfa6d1..734f75d4d 100644 --- a/test/patch-validator-grype-timestamp-future-integrity.test.ts +++ b/test/patch-validator-grype-timestamp-future-integrity.test.ts @@ -4,8 +4,34 @@ import { verifyStaticRuntimeBinaryEvidence } from "../scripts/lib/patch-validato const imageDigest = `sha256:${"8".repeat(64)}`; const providerDigest = `sha256:${"9".repeat(64)}`; -const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*"; +const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.21.0:*:*:*:*:*:*:*"; const opensslCpe = "cpe:2.3:a:openssl:openssl:3.5.2:*:*:*:*:*:*:*"; +const undiciPurl = "pkg:npm/undici@7.29.1"; + +function scannerOutput(identity: string): any { + return { + descriptor: { + name: "grype", + version: "0.116.1", + db: { + status: { + schemaVersion: "v6.0.2", + built: "2026-08-07T00:00:00Z", + valid: true, + }, + providers: { + nvd: { + captured: "2026-08-06T00:00:00Z", + input: providerDigest, + }, + }, + }, + }, + source: { type: identity.startsWith("pkg:") ? "purl" : "cpe", target: identity }, + matches: [], + ignoredMatches: [], + }; +} function validInput(): any { return { @@ -16,7 +42,7 @@ function validInput(): any { artifacts: [ { name: "node", - version: "24.19.0", + version: "24.21.0", locations: [{ path: "/nodejs/bin/node" }], cpes: [nodeCpe], }, @@ -31,8 +57,12 @@ function validInput(): any { embeddedRuntimeInventory: { schema_version: "noema.patch-validator-embedded-runtime-inventory.v1", validator_image_digest: imageDigest, - node_version: "24.19.0", - process_versions: { node: "24.19.0", openssl: "3.5.2" }, + node_version: "24.21.0", + process_versions: { + node: "24.21.0", + openssl: "3.5.2", + undici: "7.29.1", + }, components: [ { key: "openssl", @@ -41,6 +71,13 @@ function validInput(): any { classification: "bundled_dependency", cpe: opensslCpe, }, + { + key: "undici", + name: "undici", + version: "7.29.1", + classification: "bundled_dependency", + purl: undiciPurl, + }, ], }, embeddedVulnerabilityScan: { @@ -51,28 +88,12 @@ function validInput(): any { { key: "openssl", identity: opensslCpe, - scanner_output: { - descriptor: { - name: "grype", - version: "0.116.1", - db: { - status: { - schemaVersion: "v6.0.2", - built: "2026-08-07T00:00:00Z", - valid: true, - }, - providers: { - nvd: { - captured: "2026-08-06T00:00:00Z", - input: providerDigest, - }, - }, - }, - }, - source: { type: "cpe", target: opensslCpe }, - matches: [], - ignoredMatches: [], - }, + scanner_output: scannerOutput(opensslCpe), + }, + { + key: "undici", + identity: undiciPurl, + scanner_output: scannerOutput(undiciPurl), }, ], ignoredMatches: [], diff --git a/test/patch-validator-image-build-cache.test.ts b/test/patch-validator-image-build-cache.test.ts index 77b5c17a1..db0ec1931 100644 --- a/test/patch-validator-image-build-cache.test.ts +++ b/test/patch-validator-image-build-cache.test.ts @@ -61,9 +61,9 @@ describe("patch-validator image build cache", () => { it("retries transient scanner release download failures before failing closed", () => { expect(workflow).toContain("download_scanner_asset() {"); - expect(workflow).toContain("--retry 3"); + expect(workflow).toContain("--retry 7"); expect(workflow).toContain("--retry-all-errors"); - expect(workflow).toContain("--retry-delay 2"); - expect(workflow).toContain("--retry-max-time 90"); + expect(workflow).toContain("--retry-delay 0"); + expect(workflow).toContain("--retry-max-time 240"); }); }); diff --git a/test/patch-validator-image-build-regression.test.ts b/test/patch-validator-image-build-regression.test.ts index 89570bfda..42c2ae072 100644 --- a/test/patch-validator-image-build-regression.test.ts +++ b/test/patch-validator-image-build-regression.test.ts @@ -6,8 +6,8 @@ const imageWorkflow = readFileSync(".github/workflows/patch-validator-image.yml" describe("patch-validator exact-toolchain image build regression", () => { it("builds the static runtime with the exact Node/npm toolchain and patched c-ares source", () => { - expect(dockerfile).toContain("ARG NODE_VERSION=24.19.0"); - expect(dockerfile).toContain('test "$(/opt/node/bin/npm --version)" = "11.17.0"'); + expect(dockerfile).toContain("ARG NODE_VERSION=24.21.0"); + expect(dockerfile).toContain('test "$(/opt/node/bin/npm --version)" = "11.19.0"'); expect(dockerfile).toContain("ARG CARES_VERSION=1.34.8"); expect(dockerfile).toContain( "ARG CARES_SOURCE_SHA256=c222b6d681096f9444d2c4863d2c1174019e27cacca0a4a5c114d36dd7d7bf78", @@ -19,6 +19,10 @@ describe("patch-validator exact-toolchain image build regression", () => { expect(dockerfile).toContain( "if (process.versions.ares !== process.env.CARES_VERSION) throw new Error", ); + expect(dockerfile).toContain("ARG UNDICI_VERSION=7.29.1"); + expect(dockerfile).toContain( + "if (process.versions.undici !== process.env.UNDICI_VERSION) throw new Error", + ); expect(dockerfile).not.toContain("FROM validator_deps"); expect(dockerfile).not.toContain("FROM node:24.18.0-alpine3.24"); expect(dockerfile).not.toContain("--without-npm"); @@ -36,8 +40,8 @@ describe("patch-validator exact-toolchain image build regression", () => { it("materializes lockfile dependencies before Docker and forbids npm registry access in the image build", () => { expect(imageWorkflow).toContain("Set up exact dependency materialization toolchain"); expect(imageWorkflow).toContain("Materialize exact patch-validator dependencies"); - expect(imageWorkflow).toContain('node-version: "24.19.0"'); - expect(imageWorkflow).toContain('test "$(npm --version)" = "11.17.0"'); + expect(imageWorkflow).toContain('node-version: "24.21.0"'); + expect(imageWorkflow).toContain('test "$(npm --version)" = "11.19.0"'); expect(imageWorkflow).toContain("npm ci --include=optional --ignore-scripts --no-audit --no-fund"); expect(imageWorkflow).toContain( "uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294", @@ -84,7 +88,7 @@ describe("patch-validator exact-toolchain image build regression", () => { expect(scannerStep).toContain("--proto-redir '=https'"); expect(scannerStep).toContain("--connect-timeout 20"); expect(scannerStep).toContain("--max-time 180"); - expect(scannerStep).toContain("--retry-max-time 90"); + expect(scannerStep).toContain("--retry-max-time 240"); expect(scannerStep).toContain("sha256sum --check --strict"); }); diff --git a/test/patch-validator-image-contract.test.ts b/test/patch-validator-image-contract.test.ts index 453b99d6e..6eb681920 100644 --- a/test/patch-validator-image-contract.test.ts +++ b/test/patch-validator-image-contract.test.ts @@ -42,9 +42,9 @@ describe("patch-validator image contract", () => { expect(fromLines[0]).toMatch(/@sha256:[0-9a-f]{64}(?:\s|$)/); expect(dockerfile).not.toContain("FROM validator_deps"); - expect(dockerfile).toContain("ARG NODE_VERSION=24.19.0"); + expect(dockerfile).toContain("ARG NODE_VERSION=24.21.0"); expect(dockerfile).toContain( - "ARG NODE_SOURCE_SHA256=f6d95e10a0431ee1067fc6aabe9f762908b4716dd35324e1ddb4b1466b76659f", + "ARG NODE_SOURCE_SHA256=a6f54defb6fd7c84f41dba13d61e78e9b4e0961712cf61f29715c05f5ced94fc", ); expect(dockerfile).toContain("--fully-static"); expect(dockerfile).not.toContain("--without-npm"); @@ -52,15 +52,17 @@ describe("patch-validator image contract", () => { expect(dockerfile).toContain("WORKDIR /usr/src/node"); expect(dockerfile).not.toContain("&& cd /usr/src/node"); expect(dockerfile).toContain( - 'test "$(/opt/node/bin/npm --version)" = "11.17.0"', + 'test "$(/opt/node/bin/npm --version)" = "11.19.0"', ); + expect(dockerfile).toContain("ARG UNDICI_VERSION=7.29.1"); + expect(dockerfile).toContain("process.versions.undici"); expect(dockerfile).toContain("readelf -l /opt/node/bin/node"); expect(dockerfile).toContain("readelf -d /opt/node/bin/node"); expect(dockerfile).not.toContain("npm ci"); expect(dockerfile).not.toContain("npm prune"); - expect(imageWorkflow).toContain('node-version: "24.19.0"'); - expect(imageWorkflow).toContain('test "$(npm --version)" = "11.17.0"'); + expect(imageWorkflow).toContain('node-version: "24.21.0"'); + expect(imageWorkflow).toContain('test "$(npm --version)" = "11.19.0"'); expect(imageWorkflow).toContain( "npm ci --include=optional --ignore-scripts --no-audit --no-fund", ); diff --git a/test/patch-validator-image-documentation-current.test.ts b/test/patch-validator-image-documentation-current.test.ts index 8402b36c1..e1293a19c 100644 --- a/test/patch-validator-image-documentation-current.test.ts +++ b/test/patch-validator-image-documentation-current.test.ts @@ -20,7 +20,7 @@ describe("patch-validator image documentation", () => { .find((line) => line.startsWith("- repository-owned patch-validator image")); expect(imageEntry).toBeDefined(); - expect(imageEntry).toContain("Node.js 24.19.0"); + expect(imageEntry).toContain("Node.js 24.21.0"); expect(imageEntry).toContain("`scratch`"); expect(imageEntry).toContain("`process.versions`"); expect(imageEntry).not.toContain("Distroless runtime digest"); diff --git a/test/patch-validator-openssl-quic-applicability.test.ts b/test/patch-validator-openssl-quic-applicability.test.ts index eed887bd4..08d37fee6 100644 --- a/test/patch-validator-openssl-quic-applicability.test.ts +++ b/test/patch-validator-openssl-quic-applicability.test.ts @@ -5,8 +5,9 @@ import { verifyStaticRuntimeBinaryEvidence } from "../scripts/lib/patch-validato const imageDigest = `sha256:${"2".repeat(64)}`; const providerDigest = `sha256:${"a".repeat(64)}`; -const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*"; +const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.21.0:*:*:*:*:*:*:*"; const opensslCpe = "cpe:2.3:a:openssl:openssl:3.5.7:*:*:*:*:*:*:*"; +const undiciPurl = "pkg:npm/undici@7.29.1"; function scannerOutput(vulnerabilityId: string) { return { @@ -54,8 +55,9 @@ function scannerOutput(vulnerabilityId: string) { function inputFor(vulnerabilityId: string, includeDisabledQuicEvidence: boolean) { const processVersions: Record = { - node: "24.19.0", + node: "24.21.0", openssl: "3.5.7", + undici: "7.29.1", }; const components: any[] = [ { @@ -65,6 +67,13 @@ function inputFor(vulnerabilityId: string, includeDisabledQuicEvidence: boolean) classification: "bundled_dependency", cpe: opensslCpe, }, + { + key: "undici", + name: "undici", + version: "7.29.1", + classification: "bundled_dependency", + purl: undiciPurl, + }, ]; if (includeDisabledQuicEvidence) { @@ -96,7 +105,7 @@ function inputFor(vulnerabilityId: string, includeDisabledQuicEvidence: boolean) artifacts: [ { name: "node", - version: "24.19.0", + version: "24.21.0", locations: [{ path: "/nodejs/bin/node" }], cpes: [nodeCpe], }, @@ -111,7 +120,7 @@ function inputFor(vulnerabilityId: string, includeDisabledQuicEvidence: boolean) embeddedRuntimeInventory: { schema_version: "noema.patch-validator-embedded-runtime-inventory.v1", validator_image_digest: imageDigest, - node_version: "24.19.0", + node_version: "24.21.0", process_versions: processVersions, components, }, @@ -126,6 +135,15 @@ function inputFor(vulnerabilityId: string, includeDisabledQuicEvidence: boolean) identity: opensslCpe, scanner_output: scannerOutput(vulnerabilityId), }, + { + key: "undici", + identity: undiciPurl, + scanner_output: { + ...scannerOutput(vulnerabilityId), + source: { type: "purl", target: undiciPurl }, + matches: [], + }, + }, ], }, }; diff --git a/test/patch-validator-package-note-identity.test.ts b/test/patch-validator-package-note-identity.test.ts index a07694357..ecb7d659f 100644 --- a/test/patch-validator-package-note-identity.test.ts +++ b/test/patch-validator-package-note-identity.test.ts @@ -14,7 +14,7 @@ describe("patch-validator package note identity", () => { expect(dockerfile).not.toContain("pkg:generic/"); expect(dockerfile).toContain( - '"cpe":"cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*"', + '"cpe":"cpe:2.3:a:nodejs:node.js:24.21.0:*:*:*:*:*:*:*"', ); }); diff --git a/test/patch-validator-reviewed-applicability.test.ts b/test/patch-validator-reviewed-applicability.test.ts index f5ceb6ce7..656d65149 100644 --- a/test/patch-validator-reviewed-applicability.test.ts +++ b/test/patch-validator-reviewed-applicability.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it } from "vitest"; import { applyReviewedEmbeddedRuntimeApplicability } from "../scripts/lib/patch-validator-embedded-runtime-applicability.mjs"; -const nghttp2Cpe = "cpe:2.3:a:nghttp2:nghttp2:1.69.0:*:*:*:*:*:*:*"; +const nghttp2Cpe = "cpe:2.3:a:nghttp2:nghttp2:1.70.0:*:*:*:*:*:*:*"; const v8Cpe = "cpe:2.3:a:google:v8:13.6.233.17:*:*:*:*:*:*:*"; -const sqliteCpe = "cpe:2.3:a:sqlite:sqlite:3.53.3:*:*:*:*:*:*:*"; -const zlibCpe = "cpe:2.3:a:zlib:zlib:1.3.2.1-motley-3246f1b:*:*:*:*:*:*:*"; +const sqliteCpe = "cpe:2.3:a:sqlite:sqlite:3.53.4:*:*:*:*:*:*:*"; +const zlibCpe = "cpe:2.3:a:zlib:zlib:1.3.2.1-motley-8002e91:*:*:*:*:*:*:*"; function componentScan( key: string, @@ -45,40 +45,40 @@ function componentScan( function inventory() { return { - node_version: "24.19.0", + node_version: "24.21.0", process_versions: { - node: "24.19.0", - nghttp2: "1.69.0", - sqlite: "3.53.3", - v8: "13.6.233.17-node.51", - zlib: "1.3.2.1-motley-3246f1b", + node: "24.21.0", + nghttp2: "1.70.0", + sqlite: "3.53.4", + v8: "13.6.233.17-node.53", + zlib: "1.3.2.1-motley-8002e91", }, components: [ { key: "nghttp2", name: "nghttp2", - version: "1.69.0", + version: "1.70.0", classification: "bundled_dependency", cpe: nghttp2Cpe, }, { key: "sqlite", name: "sqlite", - version: "3.53.3", + version: "3.53.4", classification: "bundled_dependency", cpe: sqliteCpe, }, { key: "v8", name: "v8", - version: "13.6.233.17-node.51", + version: "13.6.233.17-node.53", classification: "bundled_dependency", cpe: v8Cpe, }, { key: "zlib", name: "zlib", - version: "1.3.2.1-motley-3246f1b", + version: "1.3.2.1-motley-8002e91", classification: "bundled_dependency", cpe: zlibCpe, }, @@ -93,8 +93,8 @@ describe("reviewed embedded-runtime applicability", () => { componentScan( "nghttp2", "nghttp2", - "1.69.0", - "1.69.0", + "1.70.0", + "1.70.0", nghttp2Cpe, "CVE-2026-58055", "Medium", @@ -116,14 +116,14 @@ describe("reviewed embedded-runtime applicability", () => { }); it.each(["CVE-2015-5380", "CVE-2011-5037", "CVE-2011-3886"])( - "marks legacy V8 advisory %s non-applicable to the exact Node 24.19.0 V8 runtime even when Grype reports the normalized CPE version", + "marks legacy V8 advisory %s non-applicable to the exact Node 24.21.0 V8 runtime even when Grype reports the normalized CPE version", (vulnerabilityId) => { const scan = { components: [ componentScan( "v8", "v8", - "13.6.233.17-node.51", + "13.6.233.17-node.53", "13.6.233.17", v8Cpe, vulnerabilityId, @@ -141,18 +141,18 @@ describe("reviewed embedded-runtime applicability", () => { expect(reviewed.nonApplicableMatches).toContainEqual({ component_key: "v8", vulnerability_id: vulnerabilityId, - reason: "Exact Node 24.19.0 V8 runtime is newer than the reviewed affected legacy V8 releases", + reason: "Exact Node 24.21.0 V8 runtime is newer than the reviewed affected legacy V8 releases", }); }, ); - it("marks CVE-2026-85046 non-applicable only to the exact Node 24.19.0 V8 branch/CPE", () => { + it("marks CVE-2026-85046 non-applicable only to the exact Node 24.21.0 V8 branch/CPE", () => { const scan = { components: [ componentScan( "v8", "v8", - "13.6.233.17-node.51", + "13.6.233.17-node.53", "13.6.233.17", v8Cpe, "CVE-2026-85046", @@ -170,7 +170,7 @@ describe("reviewed embedded-runtime applicability", () => { expect(reviewed.nonApplicableMatches).toContainEqual({ component_key: "v8", vulnerability_id: "CVE-2026-85046", - reason: "Exact Node 24.19.0 V8 branch lacks the vulnerable inlined Array.prototype.sort reducers", + reason: "Exact Node 24.21.0 V8 branch lacks the vulnerable inlined Array.prototype.sort reducers", }); const differentNode = inventory(); @@ -189,15 +189,15 @@ describe("reviewed embedded-runtime applicability", () => { "BIT-sqlite-2025-29088", "BIT-sqlite-2025-6965", ])( - "marks reviewed fixed-range SQLite finding %s non-applicable to exact SQLite 3.53.3", + "marks reviewed fixed-range SQLite finding %s non-applicable to exact SQLite 3.53.4", (vulnerabilityId) => { const scan = { components: [ componentScan( "sqlite", "sqlite", - "3.53.3", - "3.53.3", + "3.53.4", + "3.53.4", sqliteCpe, vulnerabilityId, "High", @@ -214,7 +214,7 @@ describe("reviewed embedded-runtime applicability", () => { expect(reviewed.nonApplicableMatches).toContainEqual({ component_key: "sqlite", vulnerability_id: vulnerabilityId, - reason: "Exact SQLite 3.53.3 runtime is newer than the reviewed affected SQLite ranges", + reason: "Exact SQLite 3.53.4 runtime is newer than the reviewed affected SQLite ranges", }); }, ); @@ -225,8 +225,8 @@ describe("reviewed embedded-runtime applicability", () => { componentScan( "zlib", "zlib", - "1.3.2.1-motley-3246f1b", - "1.3.2.1-motley-3246f1b", + "1.3.2.1-motley-8002e91", + "1.3.2.1-motley-8002e91", zlibCpe, "GHSA-g857-hhfv-j68w", "Medium", @@ -253,8 +253,8 @@ describe("reviewed embedded-runtime applicability", () => { componentScan( "sqlite", "sqlite", - "3.53.3", - "3.53.3", + "3.53.4", + "3.53.4", sqliteCpe, "CVE-2099-4242", "High", @@ -277,8 +277,8 @@ describe("reviewed embedded-runtime applicability", () => { componentScan( "nghttp2", "nghttp2", - "1.69.0", - "1.69.0", + "1.70.0", + "1.70.0", nghttp2Cpe, "CVE-2026-58055", "Medium", diff --git a/test/patch-validator-scanner-artifact-version.test.ts b/test/patch-validator-scanner-artifact-version.test.ts index 16aeabc1d..eed4f56c8 100644 --- a/test/patch-validator-scanner-artifact-version.test.ts +++ b/test/patch-validator-scanner-artifact-version.test.ts @@ -7,7 +7,7 @@ describe("patch-validator embedded scanner artifact version binding", () => { expect( expectedScannerArtifactVersion( "cpe:2.3:a:google:v8:13.6.233.17:*:*:*:*:*:*:*", - "13.6.233.17-node.51", + "13.6.233.17-node.53", ), ).toBe("13.6.233.17"); }); @@ -26,7 +26,7 @@ describe("patch-validator embedded scanner artifact version binding", () => { it("fails closed instead of accepting an unreviewable CPE shape", () => { expect(() => - expectedScannerArtifactVersion("cpe:2.3:a:google:v8", "13.6.233.17-node.51"), + expectedScannerArtifactVersion("cpe:2.3:a:google:v8", "13.6.233.17-node.53"), ).toThrow(/reviewed CPE identity is invalid/i); }); }); diff --git a/test/patch-validator-static-binary-vulnerability-scan.test.ts b/test/patch-validator-static-binary-vulnerability-scan.test.ts index 8a5e2388c..e689fbf22 100644 --- a/test/patch-validator-static-binary-vulnerability-scan.test.ts +++ b/test/patch-validator-static-binary-vulnerability-scan.test.ts @@ -79,6 +79,6 @@ describe("patch-validator static binary vulnerability boundary", () => { expect(staticRuntimeVerifier).toContain("binarySbom"); expect(staticRuntimeVerifier).toContain("binaryVulnerabilityScan"); expect(staticRuntimeVerifier).toContain("cpe:2.3:a:nodejs:node.js:"); - expect(staticRuntimeVerifier).toContain("24.19.0"); + expect(staticRuntimeVerifier).toContain("24.21.0"); }); }); diff --git a/test/patch-validator-static-runtime-evidence.test.ts b/test/patch-validator-static-runtime-evidence.test.ts index f0789ccbe..d1557cc5e 100644 --- a/test/patch-validator-static-runtime-evidence.test.ts +++ b/test/patch-validator-static-runtime-evidence.test.ts @@ -4,9 +4,9 @@ import { verifyStaticRuntimeBinaryEvidence } from "../scripts/lib/patch-validato const imageDigest = `sha256:${"2".repeat(64)}`; const providerDigest = `sha256:${"a".repeat(64)}`; -const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*"; +const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.21.0:*:*:*:*:*:*:*"; const opensslCpe = "cpe:2.3:a:openssl:openssl:3.5.2:*:*:*:*:*:*:*"; -const undiciPurl = "pkg:npm/undici@7.13.0"; +const undiciPurl = "pkg:npm/undici@7.29.1"; function rawScannerOutput( identity: string, @@ -77,7 +77,7 @@ function validInput(): any { artifacts: [ { name: "node", - version: "24.19.0", + version: "24.21.0", locations: [{ path: "/nodejs/bin/node" }], cpes: [{ cpe: nodeCpe, source: "syft-generated" }], }, @@ -107,11 +107,11 @@ function validInput(): any { embeddedRuntimeInventory: { schema_version: "noema.patch-validator-embedded-runtime-inventory.v1", validator_image_digest: imageDigest, - node_version: "24.19.0", + node_version: "24.21.0", process_versions: { - node: "24.19.0", + node: "24.21.0", openssl: "3.5.2", - undici: "7.13.0", + undici: "7.29.1", }, components: [ { @@ -124,7 +124,7 @@ function validInput(): any { { key: "undici", name: "undici", - version: "7.13.0", + version: "7.29.1", classification: "bundled_dependency", purl: undiciPurl, }, @@ -145,7 +145,7 @@ function validInput(): any { { artifact: { name: "undici", - version: "7.13.0", + version: "7.29.1", purl: undiciPurl, }, vulnerability: { id: "GHSA-2099-0001", severity: "Low" }, @@ -165,7 +165,7 @@ describe("static runtime binary evidence verifier", () => { expect(verifyStaticRuntimeBinaryEvidence(validInput())).toEqual({ binary_cataloger: "syft@1.50.0", binary_vulnerability_scanner: "grype@0.116.1", - node_runtime_version: "24.19.0", + node_runtime_version: "24.21.0", binary_package_count: 2, binary_vulnerability_match_count: 2, blocked_binary_vulnerability_count: 0, @@ -176,12 +176,29 @@ describe("static runtime binary evidence verifier", () => { }); }); + it("rejects internally consistent evidence for a non-reviewed Undici version", () => { + const input = validInput(); + const stalePurl = "pkg:npm/undici@7.29.0"; + input.embeddedRuntimeInventory.process_versions.undici = "7.29.0"; + input.embeddedRuntimeInventory.components[1].version = "7.29.0"; + input.embeddedRuntimeInventory.components[1].purl = stalePurl; + input.embeddedVulnerabilityScan.components[1] = { + key: "undici", + identity: stalePurl, + scanner_output: rawScannerOutput(stalePurl), + }; + + expect(() => verifyStaticRuntimeBinaryEvidence(input)).toThrow( + /undici.*reviewed fixed version 7\.29\.1/i, + ); + }); + it("accepts Syft imageId spelling, accessPath, and string CPE serialization", () => { const input = validInput(); input.binarySbom.source.metadata = { imageId: imageDigest }; input.binarySbom.artifacts[0].locations = [{ accessPath: "/nodejs/bin/node" }]; input.binarySbom.artifacts[0].cpes = [nodeCpe]; - expect(verifyStaticRuntimeBinaryEvidence(input).node_runtime_version).toBe("24.19.0"); + expect(verifyStaticRuntimeBinaryEvidence(input).node_runtime_version).toBe("24.21.0"); }); it("rejects a blocking advisory on an embedded runtime dependency even when the Node CPE lane is clean", () => { @@ -189,7 +206,7 @@ describe("static runtime binary evidence verifier", () => { input.embeddedRuntimeInventory = { schema_version: "noema.patch-validator-embedded-runtime-inventory.v1", validator_image_digest: imageDigest, - node_version: "24.19.0", + node_version: "24.21.0", components: [ { key: "openssl", @@ -237,8 +254,8 @@ describe("static runtime binary evidence verifier", () => { ["exactly one expected static Node", (x) => { x.binarySbom.artifacts[0].locations = [{ path: "/other" }]; }], ["exactly one expected static Node", (x) => { x.binarySbom.artifacts[0].cpes = null; }], ["Syft package CPE", (x) => { x.binarySbom.artifacts[0].cpes = [42]; }], - ["exactly one expected static Node", (x) => { x.binarySbom.artifacts[0].cpes = [{ cpe: "cpe:2.3:a:other:node:24.19.0:*:*:*:*:*:*:*" }]; }], - ["exactly one expected static Node", (x) => { x.binarySbom.artifacts[0].cpes = ["cpe:2.3:a:other:node:24.19.0:*:*:*:*:*:*:*"]; }], + ["exactly one expected static Node", (x) => { x.binarySbom.artifacts[0].cpes = [{ cpe: "cpe:2.3:a:other:node:24.21.0:*:*:*:*:*:*:*" }]; }], + ["exactly one expected static Node", (x) => { x.binarySbom.artifacts[0].cpes = ["cpe:2.3:a:other:node:24.21.0:*:*:*:*:*:*:*"]; }], ["exactly one expected static Node", (x) => { x.binarySbom.artifacts.push({ ...x.binarySbom.artifacts[0] }); }], ["Grype vulnerability record", (x) => { x.binaryVulnerabilityScan = null; }], ["Grype descriptor", (x) => { x.binaryVulnerabilityScan.descriptor = null; }], @@ -274,9 +291,9 @@ describe("static runtime binary evidence verifier", () => { ["ignored embedded runtime vulnerability", (x) => { x.embeddedVulnerabilityScan.ignoredMatches = [{}]; }], ["process.versions", (x) => { x.embeddedRuntimeInventory.process_versions = null; }], ["process.versions Node version", (x) => { x.embeddedRuntimeInventory.process_versions.node = "24.18.0"; }], - ["dependencies must be", (x) => { x.embeddedRuntimeInventory.process_versions = { node: "24.19.0" }; }], + ["dependencies must be", (x) => { x.embeddedRuntimeInventory.process_versions = { node: "24.21.0" }; }], ["dependencies must be", (x) => { - x.embeddedRuntimeInventory.process_versions = { node: "24.19.0" }; + x.embeddedRuntimeInventory.process_versions = { node: "24.21.0" }; for (let index = 0; index < 129; index += 1) x.embeddedRuntimeInventory.process_versions[`dep${index}`] = "1"; }], ["embedded runtime component", (x) => { x.embeddedRuntimeInventory.components[0] = null; }], diff --git a/test/patch-validator-static-runtime-identity-binding.test.ts b/test/patch-validator-static-runtime-identity-binding.test.ts index a57339bdc..97e395159 100644 --- a/test/patch-validator-static-runtime-identity-binding.test.ts +++ b/test/patch-validator-static-runtime-identity-binding.test.ts @@ -5,9 +5,9 @@ import { verifyStaticRuntimeBinaryEvidence } from "../scripts/lib/patch-validato const imageDigest = `sha256:${"4".repeat(64)}`; const providerDigestA = `sha256:${"a".repeat(64)}`; const providerDigestB = `sha256:${"b".repeat(64)}`; -const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*"; +const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.21.0:*:*:*:*:*:*:*"; const opensslCpe = "cpe:2.3:a:openssl:openssl:3.5.2:*:*:*:*:*:*:*"; -const undiciPurl = "pkg:npm/undici@7.13.0"; +const undiciPurl = "pkg:npm/undici@7.29.1"; function rawScannerOutput( identity: string, @@ -67,7 +67,7 @@ function validInput(): any { artifacts: [ { name: "node", - version: "24.19.0", + version: "24.21.0", locations: [{ path: "/nodejs/bin/node" }], cpes: [nodeCpe], }, @@ -82,11 +82,11 @@ function validInput(): any { embeddedRuntimeInventory: { schema_version: "noema.patch-validator-embedded-runtime-inventory.v1", validator_image_digest: imageDigest, - node_version: "24.19.0", + node_version: "24.21.0", process_versions: { - node: "24.19.0", + node: "24.21.0", openssl: "3.5.2", - undici: "7.13.0", + undici: "7.29.1", }, components: [ { @@ -99,7 +99,7 @@ function validInput(): any { { key: "undici", name: "undici", - version: "7.13.0", + version: "7.29.1", classification: "bundled_dependency", purl: undiciPurl, }, diff --git a/test/patch-validator-static-runtime-metadata.test.ts b/test/patch-validator-static-runtime-metadata.test.ts index 9f6099c06..81a59f278 100644 --- a/test/patch-validator-static-runtime-metadata.test.ts +++ b/test/patch-validator-static-runtime-metadata.test.ts @@ -4,8 +4,9 @@ import { verifyStaticRuntimeBinaryEvidence } from "../scripts/lib/patch-validato const imageDigest = `sha256:${"7".repeat(64)}`; const providerDigest = `sha256:${"8".repeat(64)}`; -const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*"; +const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.21.0:*:*:*:*:*:*:*"; const opensslCpe = "cpe:2.3:a:openssl:openssl:3.5.2:*:*:*:*:*:*:*"; +const undiciPurl = "pkg:npm/undici@7.29.1"; function rawScannerOutput(identity: string): any { return { @@ -44,7 +45,7 @@ function inputWithRuntimeMetadata(): any { artifacts: [ { name: "node", - version: "24.19.0", + version: "24.21.0", locations: [{ path: "/nodejs/bin/node" }], cpes: [nodeCpe], }, @@ -59,12 +60,13 @@ function inputWithRuntimeMetadata(): any { embeddedRuntimeInventory: { schema_version: "noema.patch-validator-embedded-runtime-inventory.v1", validator_image_digest: imageDigest, - node_version: "24.19.0", + node_version: "24.21.0", process_versions: { - node: "24.19.0", + node: "24.21.0", modules: "137", napi: "10", openssl: "3.5.2", + undici: "7.29.1", }, components: [ { @@ -88,6 +90,13 @@ function inputWithRuntimeMetadata(): any { classification: "bundled_dependency", cpe: opensslCpe, }, + { + key: "undici", + name: "undici", + version: "7.29.1", + classification: "bundled_dependency", + purl: undiciPurl, + }, ], }, embeddedVulnerabilityScan: { @@ -100,6 +109,11 @@ function inputWithRuntimeMetadata(): any { identity: opensslCpe, scanner_output: rawScannerOutput(opensslCpe), }, + { + key: "undici", + identity: undiciPurl, + scanner_output: rawScannerOutput(undiciPurl), + }, ], ignoredMatches: [], }, @@ -126,7 +140,7 @@ function addNgtcp2(input: any, version: string): void { describe("static runtime metadata classification", () => { it("keeps ABI metadata exhaustive without pretending it is a vulnerable package", () => { expect(verifyStaticRuntimeBinaryEvidence(inputWithRuntimeMetadata())).toMatchObject({ - embedded_runtime_component_count: 3, + embedded_runtime_component_count: 4, embedded_runtime_vulnerability_match_count: 0, blocked_embedded_runtime_vulnerability_count: 0, }); @@ -175,7 +189,7 @@ describe("static runtime metadata classification", () => { ); }); - it("does not let a scanner blind spot clear the bundled ngtcp2 version from Node 24.19.0", () => { + it("does not let a scanner blind spot clear the bundled ngtcp2 version from Node 24.21.0", () => { const input = inputWithRuntimeMetadata(); addNgtcp2(input, "1.15.1"); diff --git a/test/patch-validator-static-runtime.test.ts b/test/patch-validator-static-runtime.test.ts index 89b560726..2c62451d8 100644 --- a/test/patch-validator-static-runtime.test.ts +++ b/test/patch-validator-static-runtime.test.ts @@ -11,13 +11,13 @@ const workflow = readFileSync( const alpineBuilder = "alpine:3.24.1@sha256:79ff19e9084a00eece421b2523fb93e22d730e2c0e525905de047e848e56d95f"; const nodeSourceSha256 = - "f6d95e10a0431ee1067fc6aabe9f762908b4716dd35324e1ddb4b1466b76659f"; -const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*"; + "a6f54defb6fd7c84f41dba13d61e78e9b4e0961712cf61f29715c05f5ced94fc"; +const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.21.0:*:*:*:*:*:*:*"; describe("patch-validator static scratch runtime", () => { it("builds and inventories the checksum-pinned current Node 24 source", () => { expect(dockerfile).toContain(`FROM ${alpineBuilder} AS node_builder`); - expect(dockerfile).toContain("ARG NODE_VERSION=24.19.0"); + expect(dockerfile).toContain("ARG NODE_VERSION=24.21.0"); expect(dockerfile).toContain(`ARG NODE_SOURCE_SHA256=${nodeSourceSha256}`); expect(dockerfile).toContain( [ @@ -36,8 +36,10 @@ describe("patch-validator static scratch runtime", () => { expect(dockerfile).not.toContain("--with-intl=none"); expect(dockerfile).toContain("--with-intl=small-icu"); expect(dockerfile).toContain( - 'test "$(/opt/node/bin/npm --version)" = "11.17.0"', + 'test "$(/opt/node/bin/npm --version)" = "11.19.0"', ); + expect(dockerfile).toContain("ARG UNDICI_VERSION=7.29.1"); + expect(dockerfile.match(/if \(process\.versions\.undici/g)).toHaveLength(2); expect(dockerfile).toContain("--without-corepack"); expect(dockerfile).toContain( "test \"$(/opt/node/bin/node --version)\" = \"v${NODE_VERSION}\"", @@ -79,7 +81,7 @@ describe("patch-validator static scratch runtime", () => { expect(workflow).not.toContain("keyless@distroless.iam.gserviceaccount.com"); expect(workflow).toContain("Verify static Node runtime identity"); expect(workflow).toContain( - 'test "$(docker run --rm --pull=never --entrypoint=/nodejs/bin/node "$IMAGE_TAG" --version)" = "v24.19.0"', + 'test "$(docker run --rm --pull=never --entrypoint=/nodejs/bin/node "$IMAGE_TAG" --version)" = "v24.21.0"', ); expect(workflow).toContain("--severity MEDIUM,HIGH,CRITICAL"); expect(workflow).toContain("--exit-code 1"); diff --git a/test/patch-validator-workflow.test.ts b/test/patch-validator-workflow.test.ts index dfe514ab6..2d6a8ffd1 100644 --- a/test/patch-validator-workflow.test.ts +++ b/test/patch-validator-workflow.test.ts @@ -84,6 +84,10 @@ describe("patch-validator pull-request image verification", () => { ); expect(workflow).toContain("version: v0.74.0"); expect(workflow).not.toContain("version: v0.73.0"); + expect(workflow).toContain("--retry 7"); + expect(workflow).toContain("--retry-delay 0"); + expect(workflow).toContain("--retry-max-time 240"); + expect(workflow).not.toContain("--retry-delay 2"); expect(workflow).toContain( "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", ); @@ -94,7 +98,7 @@ describe("patch-validator pull-request image verification", () => { expect(workflow).not.toContain("keyless@distroless.iam.gserviceaccount.com"); expect(workflow).toContain("Verify static Node runtime identity"); expect(workflow).toContain( - 'test "$(docker run --rm --pull=never --entrypoint=/nodejs/bin/node "$IMAGE_TAG" --version)" = "v24.19.0"', + 'test "$(docker run --rm --pull=never --entrypoint=/nodejs/bin/node "$IMAGE_TAG" --version)" = "v24.21.0"', ); expect(workflow).toContain("readelf -l \"$node_binary\""); expect(workflow).toContain("readelf -d \"$node_binary\""); diff --git a/test/private-vulnerability-reporting-ambient-token.test.ts b/test/private-vulnerability-reporting-ambient-token.test.ts index 09d380e8e..7421544a8 100644 --- a/test/private-vulnerability-reporting-ambient-token.test.ts +++ b/test/private-vulnerability-reporting-ambient-token.test.ts @@ -5,12 +5,12 @@ import { describe, expect, it } from "vitest"; const SETUP_NODE_ACTION = "actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e"; const CANONICAL_SETTING_JOB_SHA256 = - "fe43d9d1d563fd939e7464f79e73e86fd4752bf1e88922c6ae996315a3d6cb04"; + "25913611efd46734b5bdc127d3655fabaebed43863dcb0764a0b79ab4d2cf6da"; const REVIEWED_SETUP_NODE_BLOCK = [ " - name: setup node without repository token", ` uses: ${SETUP_NODE_ACTION} # v6.4.0`, " with:", - ' node-version: "24.19.0"', + ' node-version: "24.21.0"', ' token: ""', ].join("\n"); @@ -62,7 +62,7 @@ describe("private vulnerability reporting ambient token boundary", () => { " - name: setup node without repository token", ` uses: ${SETUP_NODE_ACTION}`, " with:", - ' node-version: "24.19.0"', + ' node-version: "24.21.0"', " - name: unrelated step", " with:", ' token: ""', @@ -80,11 +80,11 @@ describe("private vulnerability reporting ambient token boundary", () => { " - name: unsafe setup node", ` uses: ${SETUP_NODE_ACTION}`, " with:", - ' node-version: "24.19.0"', + ' node-version: "24.21.0"', " - name: setup node without repository token", ` uses: ${SETUP_NODE_ACTION}`, " with:", - ' node-version: "24.19.0"', + ' node-version: "24.21.0"', ' token: ""', ].join("\n"); @@ -99,11 +99,11 @@ describe("private vulnerability reporting ambient token boundary", () => { " - name: unsafe escaped setup node", ' uses: "actions/setup\\u002dnode@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e"', " with:", - ' node-version: "24.19.0"', + ' node-version: "24.21.0"', " - name: setup node without repository token", ` uses: ${SETUP_NODE_ACTION}`, " with:", - ' node-version: "24.19.0"', + ' node-version: "24.21.0"', ' token: ""', ].join("\n"); @@ -119,11 +119,11 @@ describe("private vulnerability reporting ambient token boundary", () => { " uses: >-", ` ${SETUP_NODE_ACTION}`, " with:", - ' node-version: "24.19.0"', + ' node-version: "24.21.0"', " - name: setup node without repository token", ` uses: ${SETUP_NODE_ACTION}`, " with:", - ' node-version: "24.19.0"', + ' node-version: "24.21.0"', ' token: ""', ].join("\n"); @@ -138,11 +138,11 @@ describe("private vulnerability reporting ambient token boundary", () => { " - name: unsafe quoted-key setup node", ` "uses": ${SETUP_NODE_ACTION}`, " with:", - ' node-version: "24.19.0"', + ' node-version: "24.21.0"', " - name: setup node without repository token", ` uses: ${SETUP_NODE_ACTION}`, " with:", - ' node-version: "24.19.0"', + ' node-version: "24.21.0"', ' token: ""', ].join("\n"); diff --git a/test/product-technical-gap-current-candidate-contract.test.ts b/test/product-technical-gap-current-candidate-contract.test.ts index 5502f3ee6..8aaba64b4 100644 --- a/test/product-technical-gap-current-candidate-contract.test.ts +++ b/test/product-technical-gap-current-candidate-contract.test.ts @@ -2,6 +2,16 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; describe("product-technical gap current authority", () => { + it("records the current patch-validator candidate without promoting transient scanner evidence", () => { + const baseline = readFileSync("docs/product-technical-gap-baseline.md", "utf8"); + + expect(baseline).toContain("scanner acquisition reliability"); + expect(baseline).toContain("bounded exponential retry"); + expect(baseline).toContain("HTTP 500"); + expect(baseline).toContain("Ready / Proposed / merge HOLD"); + expect(baseline).toContain("queued is not GREEN"); + }); + it("records integrated external-extension admission and the durable lifecycle successor without overclaiming completion", () => { const baseline = readFileSync("docs/product-technical-gap-baseline.md", "utf8"); diff --git a/test/readiness-scan-toolchain-integrity.test.ts b/test/readiness-scan-toolchain-integrity.test.ts index 887d8c59e..f2aa3f902 100644 --- a/test/readiness-scan-toolchain-integrity.test.ts +++ b/test/readiness-scan-toolchain-integrity.test.ts @@ -25,9 +25,9 @@ describe("saleable-readiness workflow supply-chain integrity", () => { }); it("uses the exact protected-CI Node/npm identities and frozen install flags", () => { - expect(workflow).toContain('node-version: "24.19.0"'); - expect(workflow).toContain('test "$(node --version)" = "v24.19.0"'); - expect(workflow).toContain('test "$(npm --version)" = "11.17.0"'); + expect(workflow).toContain('node-version: "24.21.0"'); + expect(workflow).toContain('test "$(node --version)" = "v24.21.0"'); + expect(workflow).toContain('test "$(npm --version)" = "11.19.0"'); expect(workflow).toContain( "npm ci --legacy-peer-deps=false --install-links=false", ); diff --git a/test/readme-customer-facing.test.ts b/test/readme-customer-facing.test.ts index 23dde3a3c..1151d0556 100644 --- a/test/readme-customer-facing.test.ts +++ b/test/readme-customer-facing.test.ts @@ -23,6 +23,7 @@ describe("README stays customer/operator facing", () => { "wrangler secret put", "GITHUB_APP_ID", "NOEMA_LLM_API_URL", + "NOEMA_LLM_API_URL_ALLOWLIST_JSON", "NOEMA_LLM_API_KEY", "contextual-orchestrator", "GitHub App permissions", diff --git a/test/release-evidence-toolchain-integrity.test.ts b/test/release-evidence-toolchain-integrity.test.ts index 551574038..6007e53da 100644 --- a/test/release-evidence-toolchain-integrity.test.ts +++ b/test/release-evidence-toolchain-integrity.test.ts @@ -8,11 +8,11 @@ describe("release evidence toolchain integrity", () => { expect(workflow).toContain( "uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0", ); - expect(workflow).toContain('node-version: "24.19.0"'); - expect(workflow).toContain('test "$(node --version)" = "v24.19.0"'); - expect(workflow).toContain('test "$(npm --version)" = "11.17.0"'); + expect(workflow).toContain('node-version: "24.21.0"'); + expect(workflow).toContain('test "$(node --version)" = "v24.21.0"'); + expect(workflow).toContain('test "$(npm --version)" = "11.19.0"'); - const toolchainIndex = workflow.indexOf('test "$(npm --version)" = "11.17.0"'); + const toolchainIndex = workflow.indexOf('test "$(npm --version)" = "11.19.0"'); const installIndex = workflow.indexOf( "npm ci --legacy-peer-deps=false --install-links=false", );