diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11e50d3f4..b5c2a1c88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,10 @@ on: branches: - main +concurrency: + group: noema-ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: verify: name: verify @@ -15,10 +19,12 @@ jobs: contents: read steps: - name: checkout - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: setup node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "24" cache: npm diff --git a/.github/workflows/maintainer-app-readiness.yml b/.github/workflows/maintainer-app-readiness.yml new file mode 100644 index 000000000..7eed9ed3b --- /dev/null +++ b/.github/workflows/maintainer-app-readiness.yml @@ -0,0 +1,206 @@ +name: maintainer-app-readiness +run-name: Noema Maintainer App pre-activation audit + +on: + # repository_dispatch is evaluated only from the default branch. A caller + # cannot select unreviewed workflow code for these privileged token mints. + repository_dispatch: + types: [maintainer-app-readiness] + +concurrency: + group: noema-maintainer-app-readiness + cancel-in-progress: true + +# GITHUB_TOKEN is used only by the trusted checkout. Audit API calls use the +# repository-scoped Maintainer token; the Reviewer token is never exposed to a +# script and supplies only authenticated App slug/installation identity output. +permissions: + contents: read + +jobs: + preflight: + name: maintainer-app-pre-activation-audit + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + steps: + - name: checkout event-bound default-branch commit + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + # For repository_dispatch, github.sha is the default-branch commit + # bound when the event was created. Do not re-resolve a moving branch. + ref: ${{ github.sha }} + persist-credentials: false + + - name: mint repository-scoped Maintainer App token + id: maintainer_app + continue-on-error: true + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.NOEMA_MAINTAINER_APP_CLIENT_ID }} + private-key: ${{ secrets.NOEMA_MAINTAINER_APP_PRIVATE_KEY }} + owner: ContextualWisdomLab + repositories: noema + permission-actions: read + permission-checks: read + permission-contents: write + permission-metadata: read + permission-pull-requests: write + permission-statuses: read + + - name: mint repository-scoped Reviewer App identity token + id: reviewer_app + continue-on-error: true + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.NOEMA_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.NOEMA_GITHUB_APP_PRIVATE_KEY }} + owner: ContextualWisdomLab + repositories: noema + permission-metadata: read + + - name: setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "24" + + - name: audit active main governance + id: governance + continue-on-error: true + env: + GH_TOKEN: ${{ steps.maintainer_app.outputs.token }} + NOEMA_GOVERNANCE_AUDIT_PATH: ${{ runner.temp }}/noema-maintainer-app-readiness/main-governance-audit.json + run: node scripts/main-governance-audit.mjs + + - name: audit effective Maintainer App identity and access + id: readiness + if: always() + continue-on-error: true + env: + GH_TOKEN: ${{ steps.maintainer_app.outputs.token }} + NOEMA_MAINTAINER_APP_SLUG: ${{ steps.maintainer_app.outputs.app-slug }} + NOEMA_MAINTAINER_INSTALLATION_ID: ${{ steps.maintainer_app.outputs.installation-id }} + NOEMA_REVIEWER_APP_SLUG: ${{ steps.reviewer_app.outputs.app-slug }} + NOEMA_REVIEWER_INSTALLATION_ID: ${{ steps.reviewer_app.outputs.installation-id }} + NOEMA_REVIEWER_LOGIN: ${{ vars.NOEMA_REVIEWER_LOGIN }} + NOEMA_MAINTENANCE_ENABLED: ${{ vars.NOEMA_MAINTENANCE_ENABLED }} + NOEMA_GOVERNANCE_AUDIT_PATH: ${{ runner.temp }}/noema-maintainer-app-readiness/main-governance-audit.json + NOEMA_MAINTAINER_READINESS_PATH: ${{ runner.temp }}/noema-maintainer-app-readiness/maintainer-app-readiness.json + run: node scripts/maintainer-app-readiness.mjs + + - name: inspect commercial-readiness loop without writes + id: dry_run + if: always() + continue-on-error: true + env: + GH_TOKEN: ${{ steps.maintainer_app.outputs.token }} + NOEMA_REVIEWER_LOGIN: ${{ vars.NOEMA_REVIEWER_LOGIN }} + MAINTAINER_APP_OUTCOME: ${{ steps.maintainer_app.outcome }} + run: | + set -euo pipefail + report_path="$RUNNER_TEMP/noema-maintainer-app-readiness/commercial-readiness-loop-dry-run.json" + write_failure_report() { + local reasonCode="$1" + local reasonDetail="$2" + FAILURE_CODE="$reasonCode" FAILURE_DETAIL="$reasonDetail" REPORT_PATH="$report_path" \ + node --input-type=module <<'NODE' + import { mkdirSync, writeFileSync } from "node:fs"; + import { dirname } from "node:path"; + + const reportPath = process.env.REPORT_PATH; + const reasonCode = process.env.FAILURE_CODE; + const reasonDetail = process.env.FAILURE_DETAIL; + const report = { + schemaVersion: 1, + repository: "ContextualWisdomLab/noema", + generatedAt: new Date().toISOString(), + apply: false, + openPullRequestCount: null, + remainingOpenPullRequestCount: null, + results: [ + { + number: null, + result: "operational_error", + reasons: [{ code: reasonCode, detail: reasonDetail }], + }, + ], + }; + mkdirSync(dirname(reportPath), { recursive: true }); + writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + NODE + } + + if [ "$MAINTAINER_APP_OUTCOME" != "success" ]; then + write_failure_report \ + "maintainer_token_unavailable" \ + "Maintainer App token mint did not succeed; no GitHub API requests were attempted." + exit 1 + fi + + set +e + node scripts/hourly-commercial-readiness.mjs --report "$report_path" + loop_status=$? + set -e + if [ "$loop_status" -ne 0 ] && [ ! -s "$report_path" ]; then + write_failure_report \ + "commercial_loop_failed" \ + "Commercial-readiness dry run failed before it could retain its bounded report." + fi + exit "$loop_status" + + - name: normalize bounded commercial-loop evidence + id: dry_run_evidence + if: always() + continue-on-error: true + env: + REPORT_PATH: ${{ runner.temp }}/noema-maintainer-app-readiness/commercial-readiness-loop-dry-run.json + run: node scripts/normalize-commercial-readiness-evidence.mjs + + - name: upload main governance evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: main-governance-audit + path: ${{ runner.temp }}/noema-maintainer-app-readiness/main-governance-audit.json + if-no-files-found: error + retention-days: 90 + + - name: upload Maintainer App readiness evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: maintainer-app-readiness + path: ${{ runner.temp }}/noema-maintainer-app-readiness/maintainer-app-readiness.json + if-no-files-found: error + retention-days: 90 + + - name: upload no-write commercial loop evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: commercial-readiness-loop-dry-run + path: ${{ runner.temp }}/noema-maintainer-app-readiness/commercial-readiness-loop-dry-run.json + if-no-files-found: error + retention-days: 90 + + - name: enforce pre-activation gates + if: always() + env: + MAINTAINER_APP_OUTCOME: ${{ steps.maintainer_app.outcome }} + REVIEWER_APP_OUTCOME: ${{ steps.reviewer_app.outcome }} + GOVERNANCE_OUTCOME: ${{ steps.governance.outcome }} + READINESS_OUTCOME: ${{ steps.readiness.outcome }} + DRY_RUN_OUTCOME: ${{ steps.dry_run.outcome }} + DRY_RUN_EVIDENCE_OUTCOME: ${{ steps.dry_run_evidence.outcome }} + run: | + set -euo pipefail + failed=0 + for gate in MAINTAINER_APP_OUTCOME REVIEWER_APP_OUTCOME GOVERNANCE_OUTCOME READINESS_OUTCOME DRY_RUN_OUTCOME DRY_RUN_EVIDENCE_OUTCOME; do + value="${!gate}" + if [ "$value" != "success" ]; then + printf '::error::%s was %s, not success.\n' "$gate" "$value" + failed=1 + fi + done + exit "$failed" diff --git a/.github/workflows/reviewer-ci.yml b/.github/workflows/reviewer-ci.yml index 908b0c2a3..f4b86a54a 100644 --- a/.github/workflows/reviewer-ci.yml +++ b/.github/workflows/reviewer-ci.yml @@ -6,6 +6,10 @@ on: branches: - main +concurrency: + group: noema-reviewer-ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index 3af9cded1..58babdff9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,11 @@ # Changelog ## Unreleased +- 전용 Maintainer GitHub App 활성화 전에 effective token을 기계적으로 감사하는 default-branch-only `maintainer-app-readiness` workflow와 `operations:preflight`를 추가. exact Maintainer/reviewer bot identity 분리, 단일 `ContextualWisdomLab/noema` repository scope, required Actions/checks/statuses/PR/contents read probes, live `main` governance PASS, no-write commercial-loop dry run을 실패-폐쇄 검증하고 bounded JSON evidence를 90일 보존한다. 별도의 Metadata-read Reviewer App token에서 인증된 `app-slug`·`installation-id`를 받아 `NOEMA_REVIEWER_LOGIN`이 실제 Reviewer App의 `[bot]` identity와 일치하는지 검증하며 Reviewer token 값은 script에 노출하지 않는다. 모든 retained source evidence는 checkout 밖 `${RUNNER_TEMP}`에 격리하고, dry-run evidence는 1 MiB·canonical UTC·exact repository·`apply=false` schema·decoded object-key uniqueness·최대 256단계 nesting으로 재검증한다. malformed/duplicate-key JSON, unknown fields, excessive nesting, symlink·descriptor swap·short read·예측 가능한 temporary-path 공격은 atomically replaced `dry_run_report_invalid` 증빙과 실패 gate로 처리한다. 이 증빙은 해당 run의 scoped token과 reviewer credential binding만 입증하며 complete App registration, key ownership, administrator bypass, break-glass ownership은 #29/#27의 독립 검토 대상으로 명시한다. - credential-bearing GitHub API와 OIDC discovery/JWKS subrequest에 요청별 10초 deadline을 추가. `Request.signal`과 호출자 `RequestInit.signal`을 `AbortSignal.any()`로 보존하면서 독립 timeout signal을 결합하고, upstream stall은 bodyless `504 blocked-timeout` 정책 응답으로 실패-폐쇄하며 timer는 모든 종료 경로에서 정리한다. - `/exchange`의 `application/json` request body를 UTF-8 wire bytes 기준 8,192 bytes로 제한. 신뢰 가능한 `Content-Length` 초과는 body read 전에 413으로 차단하고, 길이 헤더가 없거나 잘못된 요청도 stream을 bounded-read하여 chunked 우회를 막는다. 검증된 작은 body만 재구성해 downstream parser로 전달하며 OIDC/JWKS 조회·GitHub App private-key 사용·GitHub API 호출 전에 실패-폐쇄하고 body 원문은 응답·로그에 남기지 않는다. - credential-bearing GitHub API와 OIDC subrequest에 `redirect: "manual"`을 강제하고, `3xx`/redirected response를 bodyless `502`로 치환하는 fail-closed egress wrapper를 추가. exact `api.github.com` origin과 pinned GitHub Actions discovery/JWKS endpoint 외 destination은 network call 전에 차단하며, wrapper 설치 실패나 runtime 교체 감지는 `/exchange` credential 처리 전에 `503 ERR_GITHUB_API`로 중단한다. -- credential-bearing GitHub App REST 요청의 egress를 exact `https://api.github.com` origin으로 고정. 새 Worker entrypoint가 `/exchange` 전에 `GITHUB_API_BASE`의 scheme·origin·userinfo·port·path·query·fragment를 검증하고, lookalike/malformed 설정은 rate-limit·OIDC parsing·private-key 사용 전에 `503 ERR_GITHUB_API`로 실패-폐쇄하며 허용 값도 canonical origin으로 치환한다. `/health`는 설정 복구 중에도 유지하고 원본 설정값은 응답·로그에 노출하지 않는다. +- credential-bearing GitHub App REST 요청의 egress를 exact `https://api.github.com` origin으로 고정. 새 Worker entrypoint가 `/exchange` 전에 `GITHUB_API_BASE`의 scheme·origin·userinfo·port·path·query·fragment를 검증하고, lookalike/malformed 설정은 rate-limit·OIDC parsing·private-key 사용·GitHub API 호출 전에 `503 ERR_GITHUB_API`로 실패-폐쇄하며 허용 값도 canonical origin으로 치환한다. `/health`는 설정 복구 중에도 유지하고 원본 설정값은 응답·로그에 노출하지 않는다. - `src/**/*.ts` 전체에 statements·branches·functions·lines 100% coverage threshold를 강제하고, `/exchange` wrapper·OIDC replay guard·distributed limiter의 fail-closed 및 malformed-decision 경계를 회귀 테스트로 고정했다. 새 source branch가 coverage를 낮추면 CI가 즉시 실패한다. - `/exchange` distributed rate-limit identity가 없는 요청을 shared `unknown` bucket으로 합치지 않고 `503`으로 실패-폐쇄하도록 강화. Cloudflare의 `CF-Connecting-IP`가 정확히 하나의 유효한 IPv4/IPv6가 아니면 Durable Object lookup과 bearer parsing 전에 중단하고, 유효한 IPv6는 canonical form으로 정규화하여 동일 주소의 표기 차이가 rate-limit bucket을 분할하지 않도록 한다. - CI 검증 중 공개된 `undici` 취약점 묶음(GHSA-4cwx-7wf7-3272 포함)을 제거하기 위해 Wrangler→Miniflare 경유 transitive dependency를 patched `7.29.0`으로 override하고 lockfile을 재생성했다. `npm audit --audit-level=high`를 0건으로 복구하고 release gate가 취약 버전에서 실패-폐쇄하도록 유지한다. diff --git a/docs/doctoring/commercial-readiness-evidence-utf8-boundary.md b/docs/doctoring/commercial-readiness-evidence-utf8-boundary.md new file mode 100644 index 000000000..2507db7d0 --- /dev/null +++ b/docs/doctoring/commercial-readiness-evidence-utf8-boundary.md @@ -0,0 +1,25 @@ +# Commercial-readiness evidence UTF-8 boundary + +## Decision + +Noema treats the dry-run commercial-readiness artifact as an exact byte-level protocol input. The normalizer therefore decodes the bounded `Buffer` with a single reusable `TextDecoder("utf-8", { fatal: true })` before duplicate-key scanning or `JSON.parse`. A malformed UTF-8 sequence throws and is converted into the fixed `dry_run_report_invalid` report; replacement-character decoding is not accepted. + +This preserves the distinction between the bytes produced by the no-write maintenance run and the canonical evidence retained for due diligence. Without fatal decoding, JavaScript replacement semantics could convert malformed wire bytes to U+FFFD, after which an invalid byte sequence located in an allowlist-dropped field could be silently accepted as valid evidence. + +## Standards rationale + +RFC 8259 requires JSON exchanged between systems outside a closed ecosystem to use UTF-8. The WHATWG Encoding Living Standard defines a fatal decoder mode that returns an error instead of inserting U+FFFD, and its `TextDecoder` API throws a `TypeError` when fatal decoding encounters an error. Node.js 24 exposes this WHATWG-compatible API globally and documents the same failure behavior. + +The implementation does not claim formal conformance certification. It applies the interoperable UTF-8 and fail-closed decoding requirements that are relevant to this evidence boundary. + +## Verification contract + +The regression suite supplies malformed UTF-8 inside a syntactically valid, unknown JSON member. The expected result is fixed invalid evidence, proving that allowlist dropping cannot hide malformed source bytes. A companion case supplies valid Korean and punctuation text in an allowlisted reason detail and requires exact preservation, proving that the gate rejects malformed encoding rather than non-ASCII content. + +## APA 7th references + +Bray, T. (2017). *The JavaScript Object Notation (JSON) data interchange format* (RFC 8259). Internet Engineering Task Force. https://doi.org/10.17487/RFC8259 + +Node.js contributors. (2026). *Util: Class TextDecoder* (Node.js v24.11.0 documentation). Retrieved August 4, 2026, from https://nodejs.org/download/release/v24.11.0/docs/api/util.html + +WHATWG. (2026, May 21). *Encoding* (Living Standard). Retrieved August 4, 2026, from https://encoding.spec.whatwg.org/ diff --git a/docs/maintainer-app-readiness-audit.md b/docs/maintainer-app-readiness-audit.md new file mode 100644 index 000000000..dc35b6b18 --- /dev/null +++ b/docs/maintainer-app-readiness-audit.md @@ -0,0 +1,126 @@ +# Maintainer App pre-activation audit + +## Purpose + +Noema's hourly commercial-readiness loop can dispatch exact-head reviews and perform SHA-bound merges only through a dedicated Maintainer GitHub App. The write path remains intentionally disabled until operators can prove that the effective Maintainer token, authenticated Reviewer App identity, and live `main` governance satisfy the repository's fail-closed policy. In this document, **effective installation token** means the repository-scoped token actually minted and exercised during the audited workflow run. + +The default-branch-only `.github/workflows/maintainer-app-readiness.yml` workflow produces that machine-readable preflight evidence without dispatching a review, merging a pull request, changing repository configuration, or enabling `NOEMA_MAINTENANCE_ENABLED`. All retained preflight files are generated beneath the GitHub-hosted runner's temporary directory rather than the repository checkout, so a committed checkout path cannot pre-position an artifact symlink. This separation follows the NIST SSDF expectation that security requirements and evidence be integrated into software delivery and the SLSA principle of trusting reviewed platforms while verifying their artifacts (SLSA Community, 2025; Souppaya et al., 2022). + +## Trigger + +The workflow accepts only the `maintainer-app-readiness` `repository_dispatch` event. GitHub evaluates `repository_dispatch` from the default branch, so a caller cannot select workflow code from an unreviewed branch. Checkout is pinned to `github.sha`, the event-bound default-branch commit, rather than re-resolving the moving branch name after the run starts. + +```bash +gh api repos/ContextualWisdomLab/noema/dispatches \ + --method POST \ + --input - <<'JSON' +{"event_type":"maintainer-app-readiness"} +JSON +``` + +## Required configuration + +Configure these repository values before dispatching the preflight: + +- variable `NOEMA_MAINTAINER_APP_CLIENT_ID`; +- secret `NOEMA_MAINTAINER_APP_PRIVATE_KEY`; +- variable `NOEMA_GITHUB_APP_CLIENT_ID` for the existing Reviewer App; +- secret `NOEMA_GITHUB_APP_PRIVATE_KEY` for the existing Reviewer App; +- variable `NOEMA_REVIEWER_LOGIN`, including the exact `[bot]` suffix. + +Leave `NOEMA_MAINTENANCE_ENABLED` unset or different from `true` until the preflight, independently reviewed App registration evidence, and an approved activation run all pass. The preflight verifies this state and fails with `maintenance_already_enabled` if the write path has been enabled prematurely. + +The workflow creates two separately scoped installation tokens: + +1. The **Maintainer App token** is scoped to `ContextualWisdomLab/noema` with only the effective permissions needed by the commercial loop: Actions read, Checks read, Contents write, Metadata read, Pull requests write, and Commit statuses read. It is the only token exposed to governance, API-probe, and no-write dry-run scripts as `GH_TOKEN`. +2. The **Reviewer App identity token** is scoped to the same repository with Metadata read only. Its token value is never passed to a script. Only the pinned action's authenticated `app-slug` and `installation-id` outputs are supplied to the readiness evaluator so the configured reviewer login can be bound to the actual Reviewer App credentials. + +The job-level `GITHUB_TOKEN` remains Contents read and is not a write fallback. The audited commands use only Node.js built-ins and repository scripts, so the privileged job does not run `npm ci`, install repository dependencies, execute lifecycle scripts, or perform a package-manager audit. + +Both token-mint steps are allowed to continue only so the workflow can generate bounded failure evidence. Their exact outcomes remain mandatory inputs to the final gate. A failed Maintainer mint produces governance and readiness failure reports plus a fixed, no-network `maintainer_token_unavailable` dry-run artifact; it can never be interpreted as a successful preflight. + +## Enforced checks + +`npm run operations:preflight` fails closed unless all of the following are true: + +1. automated maintenance remains disabled during the pre-activation audit; +2. the Maintainer token action reports a positive installation identifier and a valid Maintainer App slug; +3. the observed Maintainer bot login exactly equals `[bot]` and is a GitHub `Bot`; +4. the Reviewer token action reports a positive installation identifier and valid Reviewer App slug; +5. `NOEMA_REVIEWER_LOGIN` exactly equals `[bot]`, and the public user lookup returns that same GitHub `Bot` login; +6. the Maintainer and reviewer bot identities differ; +7. the effective Maintainer token can enumerate exactly one repository, `ContextualWisdomLab/noema`; +8. repository metadata reports read and scoped write access but not administrator access; +9. Actions, checks, commit statuses, pull requests, and contents read probes all succeed; +10. the retained live governance report is bound to `ContextualWisdomLab/noema`, branch `main`, and status `PASS`; +11. the existing commercial-readiness loop completes without `--apply`; +12. the dry-run report is opened read-only with no symlink following, the opened descriptor's device, inode, and byte count exactly match the pre-open path metadata, the file contains 1 to 1,048,576 bytes, and its JSON parses as schema version 1 bound to `ContextualWisdomLab/noema` with `apply=false`; +13. every JSON object contains unique decoded member names, and recursive JSON nesting does not exceed 256 levels; +14. both pinned App token actions themselves complete successfully; +15. the reusable evidence normalizer accepts the report without replacing it with `dry_run_report_invalid`. + +A configured bot account by itself is not sufficient reviewer authentication. A mismatch between `NOEMA_REVIEWER_LOGIN` and the authenticated Reviewer App slug fails with `reviewer_app_login_mismatch`; a missing or malformed Reviewer App installation identity fails with `reviewer_installation_id_invalid` or `reviewer_app_slug_invalid`. + +The Maintainer installation repository endpoint is fully paginated at 100 records per page. The collector retains only bounded identity, scope, permission, probe, governance, activation-state, App-binding, and commit-binding fields. If scope is broader than the expected repository, the report retains only the effective repository count and does not persist unexpected repository names. It does not retain API response bodies, access tokens, private keys, authorization headers, or secret-derived fingerprints. GitHub CLI subprocesses are shell-free, output-bounded, pinned to `github.com`, and terminated after 20 seconds so a stalled upstream cannot consume the entire preflight window. + +The `permissions.admin` value must be explicitly present as `false`; a missing or non-boolean value remains unknown and fails closed rather than being coerced into evidence of least privilege. + +The public `GET /users/{username}` response is used only to confirm the bounded login and GitHub `Bot` account type after the configured login has already been bound to the authenticated Reviewer App slug. GitHub does not document installation `suspended_at` state in that user-profile schema, so the preflight does not infer an App installation's suspension state from a missing user field. Successful token minting and the required API probes establish that the scoped tokens are operational for the audited run; the complete installation records and suspension states remain separate administrator evidence. + +## Verification workflow controls + +The pull-request `ci` and `reviewer-ci` workflows use distinct workflow-and-PR concurrency groups with `cancel-in-progress: true`. A newer commit therefore cancels queued or running verification for the superseded head without cancelling another pull request or the other workflow. This reduces duplicate compute while preserving the rule that only checks attached to the exact current head can satisfy merge policy (GitHub, n.d.-e). + +Every external action in those workflows is pinned to a full 40-character commit SHA, including GitHub-authored actions. The repository test command executes Vitest with coverage enabled, and the production coverage set includes both `src/**/*.ts` and `scripts/normalize-commercial-readiness-evidence.mjs`; statements, branches, functions, and lines must each remain at 100 percent. Policy tests fail if coverage execution, the production include set, thresholds, concurrency isolation, immutable action pins, or runner-temporary evidence isolation regress (GitHub, n.d.-d). + +## Evidence artifacts + +Every run attempts to retain these artifacts for 90 days, including token-mint and policy failures after checkout: + +- `main-governance-audit`; +- `maintainer-app-readiness`; +- `commercial-readiness-loop-dry-run`. + +Their source files live under `${RUNNER_TEMP}/noema-maintainer-app-readiness/`, never under the checked-out repository tree. The workflow passes the same absolute runner-temporary paths to the governance collector, readiness evaluator, commercial-loop dry run, evidence normalizer, and artifact uploader. Missing files remain upload failures rather than causing a fallback to workspace-local evidence. + +Before the commercial-loop artifact is uploaded, `scripts/normalize-commercial-readiness-evidence.mjs` validates its file type, byte size, JSON shape, canonical UTC timestamp, schema version, exact repository binding, no-write mode, counters, result identifiers, full head SHAs, and bounded reason/detail fields. It first rejects symlink, directory, empty, oversized, or malformed path metadata; opens the file with `O_RDONLY | O_NOFOLLOW`; and then refuses the input unless descriptor-level `fstat` device, inode, and size still equal the pre-open `lstat` values. This binds the bytes read to the inspected regular file and closes the symlink-swap and stale-path window. + +The normalizer scans the complete bounded JSON grammar before ordinary parsing. Object keys are decoded before comparison, so literal and escaped-equivalent names are treated as duplicates within the same object; the input is rejected before JavaScript's last-key-wins behavior can erase the ambiguity. The 256-level nesting ceiling prevents a bounded-byte document from becoming an unbounded recursive parser workload. RFC 8259 recommends unique object names because duplicate-name handling differs across implementations and is not interoperable (Bray, 2017). + +The normalizer rewrites accepted evidence from an allowlisted field set so unknown nested values are not retained. Missing, empty, symlinked, swapped, short-read, oversized, malformed, duplicate-key, excessively nested, wrong-repository, noncanonical-timestamp, or `apply=true` evidence is replaced atomically with a small canonical failure report carrying `dry_run_report_invalid`; the normalization step and final pre-activation gate then fail even though the diagnostic artifact remains available. + +The one-mebibyte input and canonical-output caps prevent a trusted workflow regression from turning the artifact path into an unbounded memory or storage sink. The normalizer never persists parser exceptions or rejected source text. It creates the replacement in an unpredictable private temporary directory on the same filesystem, writes with exclusive creation and mode `0600`, renames atomically, and removes the temporary directory on both success and rollback. Realistic deterministic tests cover a blocked current-head pull request, every supported operational result and decision, complete JSON numbers/literals/strings/arrays/objects, literal and escaped duplicate object keys, malformed and non-object JSON, excessive nesting, wrong repository and schema bindings, write-enabled input, unsafe counters, oversized input and canonical output, invalid reason codes, unsafe control characters, unbounded detail fields, canonical timestamp edge cases, descriptor device/inode/size swaps, short reads, checkout-path isolation, symlink attacks, atomic rollback, and command-entry behavior. + +The primary JSON report is `${RUNNER_TEMP}/noema-maintainer-app-readiness/maintainer-app-readiness.json` during the run and the `maintainer-app-readiness` artifact after upload. It records the Maintainer App slug and installation identifier, Reviewer App slug and installation identifier, configured reviewer login, effective Maintainer repository count and exact expected scope when valid, coarse permissions, API probes, governance binding, and stable pass/failure codes. Missing artifact files are themselves workflow errors; a green preflight cannot omit its machine-readable evidence. + +GitHub-hosted public-repository artifacts can be retained for at most 90 days, so the workflow uses the platform maximum while acquisition-grade release and deployment receipts continue to use separately attested long-lived evidence paths (GitHub, n.d.-d). + +## Evidence boundary + +A passing report proves the effective Maintainer installation token minted for that workflow run, the live APIs exercised with it, and that the configured reviewer bot login matches the authenticated Reviewer App slug and positive installation identifier produced from the configured Reviewer App credentials. It does **not** prove the complete underlying GitHub App registration for either App, all repositories available to either installation before token scoping, installation suspension state, private-key ownership and rotation, administrator bypass policy, or break-glass ownership. Those facts remain independently reviewed operational evidence under issue #29 and issue #27. + +A failed report must never be converted into an activation approval by weakening permissions, removing probes, substituting `GITHUB_TOKEN`, trusting an arbitrary bot login, exposing the Reviewer token to scripts, enabling maintenance before approval, or bypassing governance. Correct the external configuration and rerun the default-branch workflow. + +NIST SP 800-218 Version 1.1 remains the final SSDF publication used for this control. The December 2025 Version 1.2 revision is an initial public draft and is tracked for future alignment rather than treated as a superseding normative requirement (Booth et al., 2025; Souppaya et al., 2022). + +## References + +Booth, H., Ogata, M., Kent, K., Souppaya, M., & Dodson, D. (2025). *Secure Software Development Framework (SSDF) version 1.2: Recommendations for mitigating the risk of software vulnerabilities* (Initial Public Draft NIST SP 800-218 Rev. 1). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218r1.ipd + +Bray, T. (2017). *The JavaScript Object Notation (JSON) data interchange format* (RFC 8259; STD 90). Internet Engineering Task Force. https://doi.org/10.17487/RFC8259 + +GitHub. (2026). *Create GitHub App token* (Version 3.2.0) [GitHub Action]. https://github.com/actions/create-github-app-token/tree/v3.2.0 + +GitHub. (n.d.-a). *Events that trigger workflows*. GitHub Docs. Retrieved August 4, 2026, from https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#repository_dispatch + +GitHub. (n.d.-b). *REST API endpoints for GitHub App installations*. GitHub Docs. Retrieved August 4, 2026, from https://docs.github.com/en/rest/apps/installations + +GitHub. (n.d.-c). *REST API endpoints for users*. GitHub Docs. Retrieved August 4, 2026, from https://docs.github.com/en/rest/users/users#get-a-user + +GitHub. (n.d.-d). *Managing GitHub Actions settings for a repository*. GitHub Docs. Retrieved August 4, 2026, from https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository + +GitHub. (n.d.-e). *Concurrency*. GitHub Docs. Retrieved August 4, 2026, from https://docs.github.com/en/actions/concepts/workflows-and-actions/concurrency + +SLSA Community. (2025). *SLSA specification* (Version 1.2). Open Source Security Foundation. https://slsa.dev/spec/v1.2/ + +Souppaya, M., Scarfone, K., & Dodson, D. (2022). *Secure Software Development Framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST SP 800-218). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218 diff --git a/package.json b/package.json index bcf8ebe9a..8ae030a15 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "release:verify:strict": "npm run typecheck && npm run test && npm run security:scan && npm run kpi:verify:strict && npm run acquisition:manifest", "security:scan": "npm audit --audit-level=high", "security:evidence": "node scripts/security-validation-evidence.mjs", - "test": "vitest run", + "test": "vitest run --coverage", "typecheck": "tsc --noEmit", "governance:audit": "node scripts/main-governance-audit.mjs", "operations:preflight": "node scripts/maintainer-app-readiness.mjs", diff --git a/scripts/normalize-commercial-readiness-evidence.mjs b/scripts/normalize-commercial-readiness-evidence.mjs new file mode 100644 index 000000000..f0f1478f2 --- /dev/null +++ b/scripts/normalize-commercial-readiness-evidence.mjs @@ -0,0 +1,561 @@ +#!/usr/bin/env node +import { + closeSync, + constants, + fstatSync, + lstatSync, + mkdirSync, + mkdtempSync, + openSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +/** Maximum accepted and persisted commercial-readiness evidence size in bytes. */ +export const MAX_REPORT_BYTES = 1_048_576; +const EXPECTED_REPOSITORY = "ContextualWisdomLab/noema"; +const DEFAULT_REPORT_PATH = "artifacts/operations/commercial-readiness-loop-dry-run.json"; +const MAX_RESULTS = 1_000; +const MAX_REASONS_PER_RESULT = 100; +const MAX_REASON_CODE_CHARS = 100; +const MAX_REASON_DETAIL_CHARS = 4_000; +const MAX_RESULT_DETAIL_CHARS = 1_000; +const MAX_JSON_NESTING_DEPTH = 256; +const fatalUtf8Decoder = new TextDecoder("utf-8", { fatal: true }); +const unsafeControlPattern = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/; +const fullShaPattern = /^[0-9a-f]{40}$/i; +const reasonCodePattern = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/; +const canonicalTimestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; +const primitivePattern = /(?:-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null)/y; +const allowedResults = new Set([ + "blocked", + "request_review", + "merge", + "review_in_progress", + "review_dispatched", + "merged", + "operational_error", +]); +const allowedDecisions = new Set(["blocked", "request_review", "merge"]); +const defaultReader = Object.freeze({ + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + readFileSync, +}); + +/** Return whether a parsed JSON value is a non-array object. */ +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Advance a scanner past the four whitespace characters permitted by JSON. */ +function skipJsonWhitespace(text, state) { + while (state.index < text.length) { + const character = text[state.index]; + if (character !== " " && character !== "\t" && character !== "\n" && character !== "\r") { + return; + } + state.index += 1; + } +} + +/** Decode one JSON string token while retaining its exact scanner boundary. */ +function parseJsonStringToken(text, state) { + const start = state.index; + state.index += 1; + let escaped = false; + while (state.index < text.length) { + const character = text[state.index]; + const code = text.charCodeAt(state.index); + if (code < 0x20) { + throw new SyntaxError("JSON strings cannot contain unescaped control characters."); + } + state.index += 1; + if (escaped) { + escaped = false; + } else if (character === "\\") { + escaped = true; + } else if (character === '"') { + return JSON.parse(text.slice(start, state.index)); + } + } + throw new SyntaxError("JSON string was not terminated."); +} + +/** Consume one JSON number, boolean, or null literal without copying the remaining input. */ +function parseJsonPrimitive(text, state) { + primitivePattern.lastIndex = state.index; + const match = primitivePattern.exec(text); + if (!match) { + throw new SyntaxError(`Unexpected JSON token at character ${state.index}.`); + } + state.index += match[0].length; + return false; +} + +/** Scan one JSON array and propagate duplicate-key evidence from nested objects. */ +function parseJsonArray(text, state, depth) { + state.index += 1; + skipJsonWhitespace(text, state); + if (text[state.index] === "]") { + state.index += 1; + return false; + } + let duplicate = false; + while (true) { + duplicate = parseJsonValue(text, state, depth) || duplicate; + skipJsonWhitespace(text, state); + if (text[state.index] === "]") { + state.index += 1; + return duplicate; + } + if (text[state.index] !== ",") { + throw new SyntaxError(`Expected an array comma at character ${state.index}.`); + } + state.index += 1; + skipJsonWhitespace(text, state); + } +} + +/** Scan one JSON object and compare fully decoded keys within that object only. */ +function parseJsonObject(text, state, depth) { + state.index += 1; + skipJsonWhitespace(text, state); + if (text[state.index] === "}") { + state.index += 1; + return false; + } + const keys = new Set(); + let duplicate = false; + while (true) { + if (text[state.index] !== '"') { + throw new SyntaxError(`Expected an object key at character ${state.index}.`); + } + const key = parseJsonStringToken(text, state); + if (keys.has(key)) { + duplicate = true; + } + keys.add(key); + skipJsonWhitespace(text, state); + if (text[state.index] !== ":") { + throw new SyntaxError(`Expected an object colon at character ${state.index}.`); + } + state.index += 1; + skipJsonWhitespace(text, state); + duplicate = parseJsonValue(text, state, depth) || duplicate; + skipJsonWhitespace(text, state); + if (text[state.index] === "}") { + state.index += 1; + return duplicate; + } + if (text[state.index] !== ",") { + throw new SyntaxError(`Expected an object comma at character ${state.index}.`); + } + state.index += 1; + skipJsonWhitespace(text, state); + } +} + +/** Scan one JSON value with a bounded recursive nesting depth. */ +function parseJsonValue(text, state, depth) { + if (depth > MAX_JSON_NESTING_DEPTH) { + throw new RangeError("JSON evidence nesting exceeds the reviewed limit."); + } + skipJsonWhitespace(text, state); + const character = text[state.index]; + if (character === "{") { + return parseJsonObject(text, state, depth + 1); + } + if (character === "[") { + return parseJsonArray(text, state, depth + 1); + } + if (character === '"') { + parseJsonStringToken(text, state); + return false; + } + return parseJsonPrimitive(text, state); +} + +/** + * Return whether valid JSON text contains a duplicate decoded key in any object. + * Keys are compared after JSON escape decoding, so `repository` and + * `reposit\\u006fry` are duplicates. Malformed or excessively nested JSON throws + * and is converted to fixed fail-closed evidence by the public normalizer. + */ +export function hasDuplicateJsonObjectKeys(text) { + if (typeof text !== "string") { + throw new TypeError("JSON evidence must be supplied as text."); + } + const state = { index: 0 }; + skipJsonWhitespace(text, state); + const duplicate = parseJsonValue(text, state, 0); + skipJsonWhitespace(text, state); + if (state.index !== text.length) { + throw new SyntaxError(`Unexpected trailing JSON content at character ${state.index}.`); + } + return duplicate; +} + +/** Require a bounded string that cannot persist unsafe control characters. */ +function boundedString(value, label, maximum) { + if (typeof value !== "string") { + throw new TypeError(`${label} must be a string.`); + } + if (value.length === 0 || value.length > maximum) { + throw new RangeError(`${label} is outside its bounded length.`); + } + if (unsafeControlPattern.test(value)) { + throw new TypeError(`${label} contains an unsafe control character.`); + } + return value; +} + +/** Require a non-negative pull-request count or the explicit unknown value. */ +function normalizedCount(value, label) { + if (value === null) { + return null; + } + if (!Number.isSafeInteger(value) || value < 0) { + throw new TypeError(`${label} must be null or a non-negative safe integer.`); + } + return value; +} + +/** Normalize one bounded fail-closed reason without retaining unknown fields. */ +function normalizeReason(reason) { + if (!isRecord(reason)) { + throw new TypeError("Each commercial-readiness reason must be an object."); + } + const code = boundedString(reason.code, "reason code", MAX_REASON_CODE_CHARS); + if (!reasonCodePattern.test(code)) { + throw new TypeError("Commercial-readiness reason codes must use snake_case."); + } + return { + code, + detail: boundedString(reason.detail, "reason detail", MAX_REASON_DETAIL_CHARS), + }; +} + +/** Normalize one pull-request result into the reviewed evidence schema. */ +function normalizeResult(result) { + if (!isRecord(result)) { + throw new TypeError("Each commercial-readiness result must be an object."); + } + const number = result.number; + if (number !== null && (!Number.isSafeInteger(number) || number <= 0)) { + throw new TypeError("Result pull-request number must be null or a positive safe integer."); + } + const resultName = boundedString(result.result, "result", 100); + if (!allowedResults.has(resultName)) { + throw new TypeError(`Unsupported commercial-readiness result ${resultName}.`); + } + if (!Array.isArray(result.reasons) || result.reasons.length > MAX_REASONS_PER_RESULT) { + throw new TypeError("Result reasons must be a bounded array."); + } + + const normalized = { number }; + if (result.headSha !== undefined) { + const headSha = boundedString(result.headSha, "head SHA", 40); + if (!fullShaPattern.test(headSha)) { + throw new TypeError("Result head SHA must be a full hexadecimal commit SHA."); + } + normalized.headSha = headSha.toLowerCase(); + } + if (result.decision !== undefined) { + const decision = boundedString(result.decision, "decision", 100); + if (!allowedDecisions.has(decision)) { + throw new TypeError(`Unsupported commercial-readiness decision ${decision}.`); + } + normalized.decision = decision; + } + normalized.result = resultName; + normalized.reasons = result.reasons.map(normalizeReason); + if (result.detail !== undefined) { + normalized.detail = boundedString( + result.detail, + "result detail", + MAX_RESULT_DETAIL_CHARS, + ); + } + return normalized; +} + +/** Build a fixed report that cannot be mistaken for successful dry-run evidence. */ +function invalidEvidenceReport(expectedRepository, now) { + return { + schemaVersion: 1, + repository: expectedRepository, + generatedAt: now().toISOString(), + apply: false, + openPullRequestCount: null, + remainingOpenPullRequestCount: null, + results: [ + { + number: null, + result: "operational_error", + reasons: [ + { + code: "dry_run_report_invalid", + detail: + "Dry-run evidence failed size, syntax, or schema validation and was replaced before artifact upload.", + }, + ], + }, + ], + }; +} + +/** Serialize a report and enforce the same one-mebibyte persisted evidence cap. */ +function serializeBounded(report) { + const content = `${JSON.stringify(report, null, 2)}\n`; + if (Buffer.byteLength(content, "utf8") > MAX_REPORT_BYTES) { + throw new RangeError("Canonical commercial-readiness evidence exceeds one mebibyte."); + } + return content; +} + +/** + * Parse, validate, and canonicalize a no-write commercial-readiness report. + * Invalid input is replaced with a fixed operational-error report and no + * untrusted parser detail or source text is retained. + */ +export function normalizeCommercialReadinessEvidence( + raw, + { + expectedRepository = EXPECTED_REPOSITORY, + now = () => new Date(), + } = {}, +) { + const fallback = () => { + const report = invalidEvidenceReport(expectedRepository, now); + return { valid: false, report, content: serializeBounded(report) }; + }; + + try { + if (!Buffer.isBuffer(raw)) { + return fallback(); + } + if (raw.byteLength === 0) { + return fallback(); + } + if (raw.byteLength > MAX_REPORT_BYTES) { + return fallback(); + } + const text = fatalUtf8Decoder.decode(raw); + if (hasDuplicateJsonObjectKeys(text)) { + return fallback(); + } + const parsed = JSON.parse(text); + if (!isRecord(parsed)) { + return fallback(); + } + if (parsed.schemaVersion !== 1) { + return fallback(); + } + if (parsed.repository !== expectedRepository) { + return fallback(); + } + if (parsed.apply !== false) { + return fallback(); + } + const generatedAt = boundedString(parsed.generatedAt, "generated timestamp", 64); + if (!canonicalTimestampPattern.test(generatedAt)) { + return fallback(); + } + const generatedAtMilliseconds = Date.parse(generatedAt); + if (Number.isNaN(generatedAtMilliseconds)) { + return fallback(); + } + if (new Date(generatedAtMilliseconds).toISOString() !== generatedAt) { + return fallback(); + } + if (!Array.isArray(parsed.results)) { + return fallback(); + } + if (parsed.results.length > MAX_RESULTS) { + return fallback(); + } + const report = { + schemaVersion: 1, + repository: expectedRepository, + generatedAt, + apply: false, + openPullRequestCount: normalizedCount( + parsed.openPullRequestCount, + "openPullRequestCount", + ), + remainingOpenPullRequestCount: normalizedCount( + parsed.remainingOpenPullRequestCount, + "remainingOpenPullRequestCount", + ), + results: parsed.results.map(normalizeResult), + }; + return { valid: true, report, content: serializeBounded(report) }; + } catch { + return fallback(); + } +} + +/** + * Return whether filesystem metadata proves a bounded regular file. + * Symlinks, directories, empty files, oversized files, and malformed adapter + * metadata are rejected before any file content is read. + */ +export function isBoundedRegularEvidence(metadata) { + if (!metadata || typeof metadata !== "object") { + return false; + } + if (typeof metadata.isSymbolicLink !== "function") { + return false; + } + if (typeof metadata.isFile !== "function") { + return false; + } + if (metadata.isSymbolicLink()) { + return false; + } + if (!metadata.isFile()) { + return false; + } + if (!Number.isSafeInteger(metadata.size)) { + return false; + } + if (metadata.size <= 0) { + return false; + } + if (metadata.size > MAX_REPORT_BYTES) { + return false; + } + return true; +} + +/** + * Read a report through a no-follow descriptor and refuse stale path metadata. + * The descriptor inode, device, and byte count must still match the path that + * was inspected before opening, which closes the symlink-swap trust gap. + */ +export function readBoundedReport(path, fileSystem = defaultReader) { + const pathMetadata = fileSystem.lstatSync(path); + if (!isBoundedRegularEvidence(pathMetadata)) { + return null; + } + const noFollow = fileSystem.constants?.O_NOFOLLOW; + if (!Number.isInteger(noFollow)) { + return null; + } + const readOnly = fileSystem.constants?.O_RDONLY; + if (!Number.isInteger(readOnly)) { + return null; + } + const descriptor = fileSystem.openSync(path, readOnly | noFollow); + try { + const openedMetadata = fileSystem.fstatSync(descriptor); + if (!isBoundedRegularEvidence(openedMetadata)) { + return null; + } + if (openedMetadata.dev !== pathMetadata.dev) { + return null; + } + if (openedMetadata.ino !== pathMetadata.ino) { + return null; + } + if (openedMetadata.size !== pathMetadata.size) { + return null; + } + const raw = fileSystem.readFileSync(descriptor); + if (!Buffer.isBuffer(raw)) { + return null; + } + if (raw.byteLength !== openedMetadata.size) { + return null; + } + return raw; + } finally { + fileSystem.closeSync(descriptor); + } +} + +/** Replace an evidence file atomically without opening a predictable path. */ +export function writeAtomically(path, content) { + const parentDirectory = dirname(path); + mkdirSync(parentDirectory, { recursive: true }); + const temporaryDirectory = mkdtempSync(join(parentDirectory, ".noema-evidence-")); + const temporaryPath = join(temporaryDirectory, "report.json"); + try { + writeFileSync(temporaryPath, content, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + renameSync(temporaryPath, path); + } finally { + rmSync(temporaryDirectory, { force: true, recursive: true }); + } +} + +/** Resolve an operator-supplied report path, using the documented default when blank. */ +export function resolveReportPath(value, currentDirectory = process.cwd()) { + const candidate = String(value ?? DEFAULT_REPORT_PATH).trim(); + return resolve(currentDirectory, candidate || DEFAULT_REPORT_PATH); +} + +/** + * Normalize one workflow artifact in place and fail its process gate when the + * source is missing, unsafe, malformed, or outside the reviewed schema. + */ +export function main({ + reportPath = resolveReportPath(process.env.REPORT_PATH), + now = () => new Date(), + readReport = readBoundedReport, + writeReport = writeAtomically, + log = (message) => console.log(message), + setExitCode = (code) => { + process.exitCode = code; + }, +} = {}) { + let raw = null; + try { + raw = readReport(reportPath); + } catch { + raw = null; + } + const result = normalizeCommercialReadinessEvidence(raw, { + expectedRepository: EXPECTED_REPOSITORY, + now, + }); + writeReport(reportPath, result.content); + log(JSON.stringify({ + reportPath, + valid: result.valid, + resultCount: result.report.results.length, + })); + if (!result.valid) { + setExitCode(1); + } + return result; +} + +/** Run the command entrypoint only when this module is the process entry file. */ +export function runAsCommand({ + argvPath = process.argv[1], + moduleUrl = import.meta.url, + execute = main, +} = {}) { + if (!argvPath) { + return false; + } + if (pathToFileURL(resolve(argvPath)).href !== moduleUrl) { + return false; + } + execute(); + return true; +} + +runAsCommand(); diff --git a/test/commercial-readiness-evidence.test.ts b/test/commercial-readiness-evidence.test.ts new file mode 100644 index 000000000..324b45e1b --- /dev/null +++ b/test/commercial-readiness-evidence.test.ts @@ -0,0 +1,681 @@ +import { + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + MAX_REPORT_BYTES, + isBoundedRegularEvidence, + main, + normalizeCommercialReadinessEvidence, + readBoundedReport, + resolveReportPath, + runAsCommand, + writeAtomically, +} from "../scripts/normalize-commercial-readiness-evidence.mjs"; + +const repository = "ContextualWisdomLab/noema"; +const fixedNow = new Date("2026-08-04T11:15:00.000Z"); +const originalReportPath = process.env.REPORT_PATH; +const originalExitCode = process.exitCode; +const temporaryDirectories: string[] = []; + +function validReport() { + return { + schemaVersion: 1, + repository, + generatedAt: "2026-08-04T11:14:00.000Z", + apply: false, + openPullRequestCount: 1, + remainingOpenPullRequestCount: 1, + results: [ + { + number: 62, + headSha: "a".repeat(40), + decision: "blocked", + result: "blocked", + reasons: [ + { + code: "noema_current_head_approval_missing", + detail: "No current-head Noema approval exists.", + }, + ], + }, + ], + }; +} + +function normalize(value: unknown) { + const raw = typeof value === "string" ? value : JSON.stringify(value); + return normalizeCommercialReadinessEvidence(Buffer.from(raw), { + expectedRepository: repository, + now: () => fixedNow, + }); +} + +function expectInvalid(value: unknown) { + const result = normalize(value); + + expect(result.valid).toBe(false); + expect(result.report.results[0].reasons[0].code).toBe("dry_run_report_invalid"); + return result; +} + +function createTemporaryDirectory(): string { + const directory = mkdtempSync(join(tmpdir(), "noema-evidence-")); + temporaryDirectories.push(directory); + return directory; +} + +function metadata({ + file = true, + symlink = false, + size = 4, + dev = 1, + ino = 2, +}: { + file?: boolean; + symlink?: boolean; + size?: number; + dev?: number; + ino?: number; +} = {}) { + return { + dev, + ino, + size, + isFile: () => file, + isSymbolicLink: () => symlink, + }; +} + +function readerAdapter({ + pathMetadata = metadata(), + openedMetadata = pathMetadata, + raw = Buffer.from("test") as unknown, + constants = { O_NOFOLLOW: 1, O_RDONLY: 2 } as Record, + fstatError, +}: { + pathMetadata?: ReturnType; + openedMetadata?: ReturnType; + raw?: unknown; + constants?: Record; + fstatError?: Error; +} = {}) { + const closed: number[] = []; + return { + closed, + adapter: { + constants, + lstatSync: () => pathMetadata, + openSync: () => 7, + fstatSync: () => { + if (fstatError) { + throw fstatError; + } + return openedMetadata; + }, + readFileSync: () => raw, + closeSync: (descriptor: number) => closed.push(descriptor), + }, + }; +} + +const invalidCases: Array<[string, unknown]> = [ + ["malformed JSON", "{not-json"], + ["null root", "null"], + ["string root", '"text"'], + ["array root", "[]"], + ["wrong schema version", { ...validReport(), schemaVersion: 2 }], + ["wrong repository", { ...validReport(), repository: "outside/repository" }], + ["write-enabled report", { ...validReport(), apply: true }], + ["non-string timestamp", { ...validReport(), generatedAt: 7 }], + ["empty timestamp", { ...validReport(), generatedAt: "" }], + ["overlong timestamp", { ...validReport(), generatedAt: "x".repeat(65) }], + [ + "timestamp control character", + { ...validReport(), generatedAt: "2026-08-04T11:14:00.000Z\u0000" }, + ], + [ + "noncanonical timestamp", + { ...validReport(), generatedAt: "August 4, 2026 11:14:00 UTC" }, + ], + [ + "unparseable canonical timestamp", + { ...validReport(), generatedAt: "2026-13-04T11:14:00.000Z" }, + ], + [ + "normalized timestamp mismatch", + { ...validReport(), generatedAt: "2026-02-30T11:14:00.000Z" }, + ], + ["non-array results", { ...validReport(), results: null }], + [ + "too many results", + { + ...validReport(), + results: Array.from({ length: 1_001 }, () => ({ + number: null, + result: "blocked", + reasons: [], + })), + }, + ], + ["noninteger pull-request count", { ...validReport(), openPullRequestCount: 1.5 }], + ["negative pull-request count", { ...validReport(), openPullRequestCount: -1 }], + [ + "unsafe pull-request count", + { ...validReport(), remainingOpenPullRequestCount: Number.MAX_SAFE_INTEGER + 1 }, + ], + ["nonobject result", { ...validReport(), results: [null] }], + [ + "noninteger result number", + { ...validReport(), results: [{ number: 1.5, result: "blocked", reasons: [] }] }, + ], + [ + "nonpositive result number", + { ...validReport(), results: [{ number: 0, result: "blocked", reasons: [] }] }, + ], + [ + "non-string result name", + { ...validReport(), results: [{ number: 1, result: 7, reasons: [] }] }, + ], + [ + "empty result name", + { ...validReport(), results: [{ number: 1, result: "", reasons: [] }] }, + ], + [ + "overlong result name", + { + ...validReport(), + results: [{ number: 1, result: "x".repeat(101), reasons: [] }], + }, + ], + [ + "result-name control character", + { ...validReport(), results: [{ number: 1, result: "blocked\u0000", reasons: [] }] }, + ], + [ + "unsupported result name", + { ...validReport(), results: [{ number: 1, result: "unknown", reasons: [] }] }, + ], + [ + "non-array reasons", + { ...validReport(), results: [{ number: 1, result: "blocked", reasons: null }] }, + ], + [ + "too many reasons", + { + ...validReport(), + results: [ + { + number: 1, + result: "blocked", + reasons: Array.from({ length: 101 }, () => ({ code: "a", detail: "b" })), + }, + ], + }, + ], + [ + "non-string head SHA", + { ...validReport(), results: [{ number: 1, result: "blocked", headSha: 7, reasons: [] }] }, + ], + [ + "overlong head SHA", + { + ...validReport(), + results: [ + { number: 1, result: "blocked", headSha: "a".repeat(41), reasons: [] }, + ], + }, + ], + [ + "nonhexadecimal head SHA", + { + ...validReport(), + results: [ + { number: 1, result: "blocked", headSha: "z".repeat(40), reasons: [] }, + ], + }, + ], + [ + "non-string decision", + { ...validReport(), results: [{ number: 1, result: "blocked", decision: 7, reasons: [] }] }, + ], + [ + "unsupported decision", + { + ...validReport(), + results: [ + { number: 1, result: "blocked", decision: "unknown", reasons: [] }, + ], + }, + ], + [ + "nonobject reason", + { ...validReport(), results: [{ number: 1, result: "blocked", reasons: [null] }] }, + ], + [ + "non-string reason code", + { + ...validReport(), + results: [ + { number: 1, result: "blocked", reasons: [{ code: 7, detail: "b" }] }, + ], + }, + ], + [ + "invalid reason code", + { + ...validReport(), + results: [ + { + number: 1, + result: "blocked", + reasons: [{ code: "Not Snake Case", detail: "b" }], + }, + ], + }, + ], + [ + "non-string reason detail", + { + ...validReport(), + results: [ + { number: 1, result: "blocked", reasons: [{ code: "a", detail: 7 }] }, + ], + }, + ], + [ + "reason-detail control character", + { + ...validReport(), + results: [ + { + number: 1, + result: "blocked", + reasons: [{ code: "a", detail: "unsafe\u0000detail" }], + }, + ], + }, + ], + [ + "non-string result detail", + { ...validReport(), results: [{ number: 1, result: "blocked", reasons: [], detail: 7 }] }, + ], + [ + "overlong result detail", + { + ...validReport(), + results: [ + { number: 1, result: "blocked", reasons: [], detail: "x".repeat(1_001) }, + ], + }, + ], +]; + +afterEach(() => { + vi.restoreAllMocks(); + if (originalReportPath === undefined) { + delete process.env.REPORT_PATH; + } else { + process.env.REPORT_PATH = originalReportPath; + } + process.exitCode = originalExitCode; + while (temporaryDirectories.length > 0) { + rmSync(temporaryDirectories.pop()!, { force: true, recursive: true }); + } +}); + +describe("commercial-readiness evidence schema", () => { + it("canonicalizes a realistic no-write pull-request report", () => { + const report = validReport(); + const result = normalize({ ...report, ignoredField: "not retained" }); + + expect(result.valid).toBe(true); + expect(result.report).toEqual(report); + expect(result.content).toBe(`${JSON.stringify(report, null, 2)}\n`); + expect(Buffer.byteLength(result.content)).toBeLessThanOrEqual(MAX_REPORT_BYTES); + }); + + it("uses documented defaults for invalid non-buffer evidence", () => { + const result = normalizeCommercialReadinessEvidence(null); + + expect(result.valid).toBe(false); + expect(result.report.repository).toBe(repository); + expect(result.report.generatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it("rejects empty and oversized buffers before parsing", () => { + expect(normalizeCommercialReadinessEvidence(Buffer.alloc(0)).valid).toBe(false); + expect( + normalizeCommercialReadinessEvidence(Buffer.alloc(MAX_REPORT_BYTES + 1)).valid, + ).toBe(false); + }); + + it.each(invalidCases)("replaces %s with fixed fail-closed evidence", (_label, input) => { + const result = expectInvalid(input); + + expect(result.report).toEqual({ + schemaVersion: 1, + repository, + generatedAt: fixedNow.toISOString(), + apply: false, + openPullRequestCount: null, + remainingOpenPullRequestCount: null, + results: [ + { + number: null, + result: "operational_error", + reasons: [ + { + code: "dry_run_report_invalid", + detail: + "Dry-run evidence failed size, syntax, or schema validation and was replaced before artifact upload.", + }, + ], + }, + ], + }); + expect(result.content).not.toContain("not-json"); + expect(result.content).not.toContain("unsafe"); + }); + + it("accepts unknown counts and canonicalizes uppercase head SHAs", () => { + const report: any = validReport(); + report.openPullRequestCount = null; + report.remainingOpenPullRequestCount = null; + report.results = [ + { + number: null, + headSha: "A".repeat(40), + result: "merge", + reasons: [], + }, + ]; + + const result = normalize(report); + + expect(result.valid).toBe(true); + expect(result.report.results[0].headSha).toBe("a".repeat(40)); + }); + + it("retains every supported operational result and decision", () => { + const report: any = validReport(); + report.results = [ + ["blocked", "blocked"], + ["request_review", "request_review"], + ["merge", "merge"], + ["review_in_progress"], + ["review_dispatched"], + ["merged"], + ["operational_error"], + ].map(([result, decision], index) => ({ + number: index + 1, + result, + reasons: [], + ...(decision ? { decision } : {}), + ...(result === "merged" ? { detail: "Squash-merged at a reviewed commit." } : {}), + })); + + const normalized = normalize(report); + + expect(normalized.valid).toBe(true); + expect(normalized.report).toEqual(report); + }); + + it("replaces evidence whose canonical representation exceeds one mebibyte", () => { + const report: any = validReport(); + const reason = { code: "a", detail: "b" }; + report.results = Array.from({ length: 1_000 }, (_, index) => ({ + number: index + 1, + result: "blocked", + reasons: Array.from({ length: 20 }, () => reason), + })); + const raw = Buffer.from(JSON.stringify(report)); + + expect(raw.byteLength).toBeLessThan(MAX_REPORT_BYTES); + const result = normalizeCommercialReadinessEvidence(raw, { + expectedRepository: repository, + now: () => fixedNow, + }); + + expect(result.valid).toBe(false); + expect(result.report.results[0].reasons[0].code).toBe("dry_run_report_invalid"); + }); +}); + +describe("commercial-readiness evidence filesystem boundary", () => { + it.each([ + ["missing metadata", null], + ["missing symlink predicate", { isFile: () => true, size: 4 }], + ["missing file predicate", { isSymbolicLink: () => false, size: 4 }], + ["symlink", metadata({ symlink: true })], + ["directory", metadata({ file: false })], + ["unsafe size", metadata({ size: Number.MAX_SAFE_INTEGER + 1 })], + ["empty file", metadata({ size: 0 })], + ["oversized file", metadata({ size: MAX_REPORT_BYTES + 1 })], + ])("rejects %s", (_label, value) => { + expect(isBoundedRegularEvidence(value)).toBe(false); + }); + + it("accepts bounded regular metadata", () => { + expect(isBoundedRegularEvidence(metadata())).toBe(true); + }); + + it("reads a stable no-follow descriptor and always closes it", () => { + const { adapter, closed } = readerAdapter(); + + expect(readBoundedReport("report.json", adapter)).toEqual(Buffer.from("test")); + expect(closed).toEqual([7]); + }); + + it.each([ + ["unsafe path metadata", { pathMetadata: metadata({ symlink: true }) }], + ["missing no-follow flag", { constants: { O_RDONLY: 2 } }], + ["missing read-only flag", { constants: { O_NOFOLLOW: 1 } }], + ["unsafe descriptor metadata", { openedMetadata: metadata({ file: false }) }], + ["device swap", { openedMetadata: metadata({ dev: 9 }) }], + ["inode swap", { openedMetadata: metadata({ ino: 9 }) }], + ["size swap", { openedMetadata: metadata({ size: 3 }) }], + ["non-buffer read", { raw: "test" }], + ["short read", { raw: Buffer.from("bad") }], + ])("fails closed on %s", (_label, options) => { + const { adapter } = readerAdapter(options); + + expect(readBoundedReport("report.json", adapter)).toBeNull(); + }); + + it("closes the descriptor when descriptor inspection throws", () => { + const { adapter, closed } = readerAdapter({ + fstatError: new Error("fstat failed"), + }); + + expect(() => readBoundedReport("report.json", adapter)).toThrow("fstat failed"); + expect(closed).toEqual([7]); + }); + + it("reads a real regular report through the production no-follow adapter", () => { + const directory = createTemporaryDirectory(); + const reportPath = join(directory, "report.json"); + writeFileSync(reportPath, "test", "utf8"); + + expect(readBoundedReport(reportPath)).toEqual(Buffer.from("test")); + }); + + it("atomically writes mode-0600 evidence and removes its private temporary directory", () => { + const directory = createTemporaryDirectory(); + const reportPath = join(directory, "report.json"); + + writeAtomically(reportPath, "evidence\n"); + + expect(readFileSync(reportPath, "utf8")).toBe("evidence\n"); + expect(statSync(reportPath).mode & 0o777).toBe(0o600); + expect(readdirSync(directory)).toEqual(["report.json"]); + }); + + it("rolls back its private temporary directory when replacement fails", () => { + const directory = createTemporaryDirectory(); + const reportPath = join(directory, "existing-directory"); + mkdirSync(reportPath); + writeFileSync(join(reportPath, "keep.txt"), "keep", "utf8"); + + expect(() => writeAtomically(reportPath, "replacement\n")).toThrow(); + + expect(readdirSync(directory)).toEqual(["existing-directory"]); + expect(readFileSync(join(reportPath, "keep.txt"), "utf8")).toBe("keep"); + }); + + it("does not follow a predictable temporary-file symlink while replacing evidence", () => { + const directory = createTemporaryDirectory(); + const reportPath = join(directory, "commercial-readiness.json"); + const protectedPath = join(directory, "protected.txt"); + const predictableTemporaryPath = `${reportPath}.${process.pid}.tmp`; + writeFileSync(reportPath, `${JSON.stringify(validReport())}\n`, "utf8"); + writeFileSync(protectedPath, "protected-sentinel\n", "utf8"); + symlinkSync(protectedPath, predictableTemporaryPath); + process.env.REPORT_PATH = reportPath; + vi.spyOn(console, "log").mockImplementation(() => undefined); + + const result = main({ now: () => fixedNow }); + + expect(result.valid).toBe(true); + expect(readFileSync(protectedPath, "utf8")).toBe("protected-sentinel\n"); + expect(lstatSync(reportPath).isFile()).toBe(true); + expect(lstatSync(reportPath).isSymbolicLink()).toBe(false); + }); + + it("does not follow a report symlink while retaining canonical failure evidence", () => { + const directory = createTemporaryDirectory(); + const reportPath = join(directory, "report.json"); + const protectedPath = join(directory, "protected.txt"); + writeFileSync(protectedPath, "protected-sentinel\n", "utf8"); + symlinkSync(protectedPath, reportPath); + const exitCodes: number[] = []; + + const result = main({ + reportPath, + now: () => fixedNow, + log: () => undefined, + setExitCode: (code: number) => exitCodes.push(code), + }); + + expect(result.valid).toBe(false); + expect(readFileSync(protectedPath, "utf8")).toBe("protected-sentinel\n"); + expect(lstatSync(reportPath).isFile()).toBe(true); + expect(exitCodes).toEqual([1]); + }); +}); + +describe("commercial-readiness evidence command boundary", () => { + it("resolves explicit, missing, and blank report paths", () => { + const currentDirectory = "/tmp/noema-current-directory"; + + expect(resolveReportPath("custom/report.json", currentDirectory)).toBe( + resolve(currentDirectory, "custom/report.json"), + ); + expect(resolveReportPath(undefined, currentDirectory)).toBe( + resolve( + currentDirectory, + "artifacts/operations/commercial-readiness-loop-dry-run.json", + ), + ); + expect(resolveReportPath(" ", currentDirectory)).toBe( + resolve( + currentDirectory, + "artifacts/operations/commercial-readiness-loop-dry-run.json", + ), + ); + }); + + it("normalizes a valid report through the production main defaults", () => { + const directory = createTemporaryDirectory(); + const reportPath = join(directory, "report.json"); + writeFileSync(reportPath, `${JSON.stringify(validReport())}\n`, "utf8"); + process.env.REPORT_PATH = reportPath; + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + const result = main({ now: () => fixedNow }); + + expect(result.valid).toBe(true); + expect(log).toHaveBeenCalledTimes(1); + }); + + it("retains canonical failure evidence when reading throws", () => { + const writes: unknown[][] = []; + const logs: string[] = []; + const exitCodes: number[] = []; + + const result = main({ + reportPath: "/bounded/report.json", + now: () => fixedNow, + readReport: () => { + throw new Error("read failed"); + }, + writeReport: (...arguments_: unknown[]) => writes.push(arguments_), + log: (message: string) => logs.push(message), + setExitCode: (code: number) => exitCodes.push(code), + }); + + expect(result.valid).toBe(false); + expect(writes).toHaveLength(1); + expect(logs).toHaveLength(1); + expect(exitCodes).toEqual([1]); + }); + + it("uses production defaults for a missing report", () => { + const directory = createTemporaryDirectory(); + const reportPath = join(directory, "missing-report.json"); + process.env.REPORT_PATH = reportPath; + process.exitCode = 0; + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + const result = main(); + + expect(result.valid).toBe(false); + expect(process.exitCode).toBe(1); + expect(log).toHaveBeenCalledTimes(1); + expect(JSON.parse(readFileSync(reportPath, "utf8")).results[0].reasons[0].code).toBe( + "dry_run_report_invalid", + ); + }); + + it("does not run without an entry path or for an imported module", () => { + const execute = vi.fn(); + + expect( + runAsCommand({ argvPath: "", moduleUrl: "file:///module.mjs", execute }), + ).toBe(false); + expect( + runAsCommand({ + argvPath: "/different.mjs", + moduleUrl: "file:///module.mjs", + execute, + }), + ).toBe(false); + expect(execute).not.toHaveBeenCalled(); + }); + + it("runs exactly once when the module URL matches the entry path", () => { + const entryPath = "/tmp/noema-command.mjs"; + const execute = vi.fn(); + + expect( + runAsCommand({ + argvPath: entryPath, + moduleUrl: pathToFileURL(resolve(entryPath)).href, + execute, + }), + ).toBe(true); + expect(execute).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/commercial-readiness-json-duplicates.test.ts b/test/commercial-readiness-json-duplicates.test.ts new file mode 100644 index 000000000..88011c55b --- /dev/null +++ b/test/commercial-readiness-json-duplicates.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { + hasDuplicateJsonObjectKeys, + normalizeCommercialReadinessEvidence, +} from "../scripts/normalize-commercial-readiness-evidence.mjs"; + +const expectedRepository = "ContextualWisdomLab/noema"; +const fixedNow = new Date("2026-08-04T12:15:00.000Z"); + +function normalizeRaw(raw: string) { + return normalizeCommercialReadinessEvidence(Buffer.from(raw), { + expectedRepository, + now: () => fixedNow, + }); +} + +describe("commercial-readiness JSON duplicate-key boundary", () => { + it("accepts complete JSON grammar without confusing values or sibling keys for duplicates", () => { + const raw = String.raw`{ + "schemaVersion": 1, + "repository": "ContextualWisdomLab/noema", + "generatedAt": "2026-08-04T12:14:00.000Z", + "apply": false, + "openPullRequestCount": 1, + "remainingOpenPullRequestCount": 1, + "results": [ + { + "number": 62, + "result": "blocked", + "reasons": [ + {"code": "blocked_reason", "detail": "quoted \"key\" and braces {}"} + ], + "ignoredObject": {"sameKey": -1.25e+3}, + "ignoredSibling": {"sameKey": true}, + "ignoredValues": [false, null, "repository", [], {}] + } + ] + }`; + + expect(hasDuplicateJsonObjectKeys(raw)).toBe(false); + expect(normalizeRaw(raw).valid).toBe(true); + }); + + it.each([ + [ + "root duplicate", + String.raw`{"schemaVersion":1,"schemaVersion":1,"repository":"ContextualWisdomLab/noema","generatedAt":"2026-08-04T12:14:00.000Z","apply":false,"openPullRequestCount":0,"remainingOpenPullRequestCount":0,"results":[]}`, + ], + [ + "nested duplicate", + String.raw`{"schemaVersion":1,"repository":"ContextualWisdomLab/noema","generatedAt":"2026-08-04T12:14:00.000Z","apply":false,"openPullRequestCount":1,"remainingOpenPullRequestCount":1,"results":[{"number":62,"result":"blocked","reasons":[{"code":"first_code","code":"second_code","detail":"duplicate"}]}]}`, + ], + [ + "escaped equivalent duplicate", + String.raw`{"schemaVersion":1,"repository":"ContextualWisdomLab/noema","reposit\u006fry":"other/repository","generatedAt":"2026-08-04T12:14:00.000Z","apply":false,"openPullRequestCount":0,"remainingOpenPullRequestCount":0,"results":[]}`, + ], + [ + "duplicate inside an array object", + String.raw`[{"nested":1,"nested":2}]`, + ], + ])("rejects %s before JSON.parse can apply last-key-wins semantics", (_label, raw) => { + expect(hasDuplicateJsonObjectKeys(raw)).toBe(true); + const result = normalizeRaw(raw); + + expect(result.valid).toBe(false); + expect(result.report.results[0].reasons[0].code).toBe("dry_run_report_invalid"); + expect(result.content).not.toContain("second_code"); + expect(result.content).not.toContain("other/repository"); + }); + + it.each([ + ["non-string input", null], + ["empty input", ""], + ["missing object key", "{"], + ["missing colon", String.raw`{"key" 1}`], + ["missing value", String.raw`{"key":}`], + ["missing object comma", String.raw`{"first":1 "second":2}`], + ["missing array comma", String.raw`[1 2]`], + ["dangling array comma", String.raw`[1,]`], + ["trailing content", String.raw`{"key":1} trailing`], + ["unterminated string", String.raw`{"key":"unterminated}`], + ["invalid escape", String.raw`{"ke\q":"value"}`], + ["unescaped control character", "{\"bad\nkey\":1}"], + ["invalid primitive", String.raw`{"key":tru}`], + ])("fails closed on %s", (_label, raw) => { + expect(() => hasDuplicateJsonObjectKeys(raw as string)).toThrow(); + }); + + it("bounds recursive JSON nesting", () => { + const raw = `${"[".repeat(258)}null${"]".repeat(258)}`; + + expect(() => hasDuplicateJsonObjectKeys(raw)).toThrow(RangeError); + expect(normalizeRaw(raw).valid).toBe(false); + }); +}); diff --git a/test/commercial-readiness-json-scan-performance.test.ts b/test/commercial-readiness-json-scan-performance.test.ts new file mode 100644 index 000000000..aa1a1534d --- /dev/null +++ b/test/commercial-readiness-json-scan-performance.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from "vitest"; +import { hasDuplicateJsonObjectKeys } from "../scripts/normalize-commercial-readiness-evidence.mjs"; + +describe("commercial-readiness JSON scanner performance contract", () => { + it("scans a dense primitive array without allocating suffix substrings", () => { + const primitiveValues = ["0", "-1.25e+3", "true", "false", "null"]; + const json = `[${Array.from( + { length: 20_000 }, + (_, index) => primitiveValues[index % primitiveValues.length], + ).join(",")}]`; + const slice = vi + .spyOn(String.prototype, "slice") + .mockImplementation(() => { + throw new Error("primitive scanning must not copy the remaining JSON suffix"); + }); + + try { + expect(hasDuplicateJsonObjectKeys(json)).toBe(false); + } finally { + slice.mockRestore(); + } + }); +}); diff --git a/test/commercial-readiness-utf8.test.ts b/test/commercial-readiness-utf8.test.ts new file mode 100644 index 000000000..077daa866 --- /dev/null +++ b/test/commercial-readiness-utf8.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { normalizeCommercialReadinessEvidence } from "../scripts/normalize-commercial-readiness-evidence.mjs"; + +const expectedRepository = "ContextualWisdomLab/noema"; +const generatedAt = "2026-08-04T12:30:00.000Z"; + +function reportPrefix(ignoredValueStart: string): Buffer { + return Buffer.from( + `{"schemaVersion":1,"repository":"${expectedRepository}","generatedAt":"${generatedAt}","apply":false,"openPullRequestCount":0,"remainingOpenPullRequestCount":0,"results":[],"ignored":"${ignoredValueStart}`, + "utf8", + ); +} + +function normalize(raw: Buffer) { + return normalizeCommercialReadinessEvidence(raw, { + expectedRepository, + now: () => new Date("2026-08-04T12:31:00.000Z"), + }); +} + +describe("commercial-readiness UTF-8 evidence boundary", () => { + it("accepts valid international UTF-8 in an allowlist-dropped field", () => { + const raw = Buffer.concat([ + reportPrefix("정상적인 국제화 증빙"), + Buffer.from('"}', "utf8"), + ]); + + const result = normalize(raw); + + expect(result.valid).toBe(true); + expect(result.content).not.toContain("정상적인 국제화 증빙"); + }); + + it("preserves valid international UTF-8 in an allowlisted reason detail", () => { + const detail = "운영 검증 완료 — 증거가 현재 헤드와 일치합니다."; + const raw = Buffer.from( + JSON.stringify({ + schemaVersion: 1, + repository: expectedRepository, + generatedAt, + apply: false, + openPullRequestCount: 1, + remainingOpenPullRequestCount: 1, + results: [ + { + number: 62, + result: "blocked", + reasons: [{ code: "review_required", detail }], + }, + ], + }), + "utf8", + ); + + const result = normalize(raw); + + expect(result.valid).toBe(true); + expect(result.report.results[0].reasons[0].detail).toBe(detail); + expect(result.content).toContain(detail); + }); + + it("rejects malformed UTF-8 before replacement decoding can hide it", () => { + const raw = Buffer.concat([ + reportPrefix(""), + Buffer.from([0xc3, 0x28]), + Buffer.from('"}', "utf8"), + ]); + + const result = normalize(raw); + + expect(result.valid).toBe(false); + expect(result.report.results[0].reasons[0].code).toBe("dry_run_report_invalid"); + expect(result.content).not.toContain("�"); + }); +}); diff --git a/test/maintainer-app-readiness-workflow-hardening.test.ts b/test/maintainer-app-readiness-workflow-hardening.test.ts new file mode 100644 index 000000000..dfb102287 --- /dev/null +++ b/test/maintainer-app-readiness-workflow-hardening.test.ts @@ -0,0 +1,108 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { + MAX_REPORT_BYTES, + normalizeCommercialReadinessEvidence, +} from "../scripts/normalize-commercial-readiness-evidence.mjs"; + +const workflow = readFileSync( + ".github/workflows/maintainer-app-readiness.yml", + "utf8", +); + +describe("maintainer App readiness workflow hardening", () => { + it("invokes reviewed Node entrypoints directly without npm lifecycle hooks", () => { + expect(workflow).not.toContain("npm ci"); + expect(workflow).not.toContain("npm install"); + expect(workflow).not.toContain("npm run"); + expect(workflow).toContain("node scripts/main-governance-audit.mjs"); + expect(workflow).toContain("node scripts/maintainer-app-readiness.mjs"); + expect(workflow).toContain( + "node scripts/normalize-commercial-readiness-evidence.mjs", + ); + }); + + it("continues after either App token mint fails so bounded failure artifacts can be written", () => { + const maintainerStart = workflow.indexOf("mint repository-scoped Maintainer App token"); + const reviewerStart = workflow.indexOf("mint repository-scoped Reviewer App identity token"); + const setupStart = workflow.indexOf("setup Node.js"); + const maintainerBlock = workflow.slice(maintainerStart, reviewerStart); + const reviewerBlock = workflow.slice(reviewerStart, setupStart); + + expect(maintainerBlock).toContain("continue-on-error: true"); + expect(reviewerBlock).toContain("continue-on-error: true"); + expect(workflow).toContain("MAINTAINER_APP_OUTCOME: ${{ steps.maintainer_app.outcome }}"); + expect(workflow).toContain("REVIEWER_APP_OUTCOME: ${{ steps.reviewer_app.outcome }}"); + expect(workflow).toContain( + "for gate in MAINTAINER_APP_OUTCOME REVIEWER_APP_OUTCOME GOVERNANCE_OUTCOME READINESS_OUTCOME DRY_RUN_OUTCOME DRY_RUN_EVIDENCE_OUTCOME", + ); + }); + + it("keeps every retained preflight artifact outside the repository checkout", () => { + const evidenceRoot = "noema-maintainer-app-readiness"; + + expect(workflow).not.toContain("artifacts/governance"); + expect(workflow).not.toContain("artifacts/operations"); + expect(workflow).toContain( + `NOEMA_GOVERNANCE_AUDIT_PATH: \${{ runner.temp }}/${evidenceRoot}/main-governance-audit.json`, + ); + expect(workflow).toContain( + `NOEMA_MAINTAINER_READINESS_PATH: \${{ runner.temp }}/${evidenceRoot}/maintainer-app-readiness.json`, + ); + expect(workflow).toContain( + `report_path="$RUNNER_TEMP/${evidenceRoot}/commercial-readiness-loop-dry-run.json"`, + ); + expect(workflow).toContain( + `REPORT_PATH: \${{ runner.temp }}/${evidenceRoot}/commercial-readiness-loop-dry-run.json`, + ); + expect(workflow).toContain( + `path: \${{ runner.temp }}/${evidenceRoot}/main-governance-audit.json`, + ); + expect(workflow).toContain( + `path: \${{ runner.temp }}/${evidenceRoot}/maintainer-app-readiness.json`, + ); + expect(workflow).toContain( + `path: \${{ runner.temp }}/${evidenceRoot}/commercial-readiness-loop-dry-run.json`, + ); + }); + + it("writes repository-bound evidence when no Maintainer token exists or the dry-run command fails early", () => { + expect(workflow).toContain("code: reasonCode"); + expect(workflow).toContain('repository: "ContextualWisdomLab/noema"'); + expect(workflow).not.toContain("process.env.GITHUB_REPOSITORY"); + expect(workflow).not.toContain('reasonCode="maintainer_token_unavailable"'); + expect(workflow).not.toContain('reasonCode="commercial_loop_failed"'); + expect(workflow).toContain('"maintainer_token_unavailable" \\\n'); + expect(workflow).toContain('"commercial_loop_failed" \\\n'); + expect(workflow).toContain( + "noema-maintainer-app-readiness/commercial-readiness-loop-dry-run.json", + ); + expect(workflow).toContain('if [ "$MAINTAINER_APP_OUTCOME" != "success" ]'); + expect(workflow).toContain('if [ "$loop_status" -ne 0 ] && [ ! -s "$report_path" ]'); + expect(workflow).toContain('exit "$loop_status"'); + }); + + it("normalizes missing, oversized, or malformed dry-run evidence before artifact upload", () => { + const normalizeStep = workflow.indexOf("normalize bounded commercial-loop evidence"); + const uploadStep = workflow.indexOf("upload no-write commercial loop evidence"); + const replaced = normalizeCommercialReadinessEvidence(Buffer.from("{")); + + expect(normalizeStep).toBeGreaterThan(0); + expect(uploadStep).toBeGreaterThan(normalizeStep); + expect(workflow).toContain( + "node scripts/normalize-commercial-readiness-evidence.mjs", + ); + expect(MAX_REPORT_BYTES).toBe(1_048_576); + expect(replaced.valid).toBe(false); + expect(replaced.report.repository).toBe("ContextualWisdomLab/noema"); + expect(replaced.report.results[0].reasons[0].code).toBe( + "dry_run_report_invalid", + ); + expect(workflow).toContain( + "DRY_RUN_EVIDENCE_OUTCOME: ${{ steps.dry_run_evidence.outcome }}", + ); + expect(workflow).toContain( + "for gate in MAINTAINER_APP_OUTCOME REVIEWER_APP_OUTCOME GOVERNANCE_OUTCOME READINESS_OUTCOME DRY_RUN_OUTCOME DRY_RUN_EVIDENCE_OUTCOME", + ); + }); +}); diff --git a/test/maintainer-app-readiness-workflow.test.ts b/test/maintainer-app-readiness-workflow.test.ts new file mode 100644 index 000000000..be0a72173 --- /dev/null +++ b/test/maintainer-app-readiness-workflow.test.ts @@ -0,0 +1,86 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const workflow = readFileSync(".github/workflows/maintainer-app-readiness.yml", "utf8"); + +describe("maintainer App readiness workflow", () => { + it("runs only from an event-bound default-branch commit", () => { + expect(workflow).toContain("repository_dispatch:"); + expect(workflow).toContain("types: [maintainer-app-readiness]"); + expect(workflow).not.toContain("workflow_dispatch:"); + expect(workflow).toContain("ref: ${{ github.sha }}"); + expect(workflow).not.toContain("ref: ${{ github.event.repository.default_branch }}"); + expect(workflow).toContain("persist-credentials: false"); + }); + + it("mints separate repository-scoped Maintainer and Reviewer App tokens", () => { + expect(workflow.match(/actions\/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1/g)).toHaveLength(2); + expect(workflow).toContain("client-id: ${{ vars.NOEMA_MAINTAINER_APP_CLIENT_ID }}"); + expect(workflow).toContain("private-key: ${{ secrets.NOEMA_MAINTAINER_APP_PRIVATE_KEY }}"); + expect(workflow).toContain("client-id: ${{ vars.NOEMA_GITHUB_APP_CLIENT_ID }}"); + expect(workflow).toContain("private-key: ${{ secrets.NOEMA_GITHUB_APP_PRIVATE_KEY }}"); + expect(workflow.match(/owner: ContextualWisdomLab/g)).toHaveLength(2); + expect(workflow.match(/repositories: noema/g)).toHaveLength(2); + expect(workflow).toContain("permission-actions: read"); + expect(workflow).toContain("permission-checks: read"); + expect(workflow).toContain("permission-contents: write"); + expect(workflow).toContain("permission-metadata: read"); + expect(workflow).toContain("permission-pull-requests: write"); + expect(workflow).toContain("permission-statuses: read"); + expect(workflow).not.toContain("permission-administration"); + + const reviewerStart = workflow.indexOf("mint repository-scoped Reviewer App identity token"); + const setupStart = workflow.indexOf("setup Node.js"); + const reviewerBlock = workflow.slice(reviewerStart, setupStart); + expect(reviewerStart).toBeGreaterThan(0); + expect(setupStart).toBeGreaterThan(reviewerStart); + expect(reviewerBlock).toContain("permission-metadata: read"); + expect(reviewerBlock).not.toContain("permission-contents: write"); + expect(reviewerBlock).not.toContain("permission-pull-requests: write"); + expect(workflow).not.toContain("GH_TOKEN: ${{ steps.reviewer_app.outputs.token }}"); + }); + + it("passes authenticated Reviewer App identity outputs to the evaluator", () => { + expect(workflow).toContain("NOEMA_REVIEWER_APP_SLUG: ${{ steps.reviewer_app.outputs.app-slug }}"); + expect(workflow).toContain("NOEMA_REVIEWER_INSTALLATION_ID: ${{ steps.reviewer_app.outputs.installation-id }}"); + expect(workflow).toContain("NOEMA_REVIEWER_LOGIN: ${{ vars.NOEMA_REVIEWER_LOGIN }}"); + }); + + it("orders gates and requires all evidence artifacts", () => { + const governance = workflow.indexOf("audit active main governance"); + const readiness = workflow.indexOf("audit effective Maintainer App identity and access"); + const dryRun = workflow.indexOf("inspect commercial-readiness loop without writes"); + const enforcement = workflow.indexOf("enforce pre-activation gates"); + + expect(governance).toBeGreaterThan(0); + expect(readiness).toBeGreaterThan(governance); + expect(dryRun).toBeGreaterThan(readiness); + expect(enforcement).toBeGreaterThan(dryRun); + expect(workflow).toContain("node scripts/maintainer-app-readiness.mjs"); + expect(workflow).toContain("NOEMA_MAINTENANCE_ENABLED: ${{ vars.NOEMA_MAINTENANCE_ENABLED }}"); + expect(workflow).toContain("scripts/hourly-commercial-readiness.mjs"); + expect(workflow).not.toContain("--apply"); + expect(workflow.match(/if-no-files-found: error/g)).toHaveLength(3); + expect(workflow).not.toContain("if-no-files-found: warn"); + expect(workflow).toContain("retention-days: 90"); + }); + + it("documents the evidence boundary and operational rollback posture", () => { + const documentation = readFileSync("docs/maintainer-app-readiness-audit.md", "utf8"); + + expect(documentation).toContain("effective installation token"); + expect(documentation).toContain("complete underlying GitHub App registration"); + expect(documentation).toContain("installation suspension state"); + expect(documentation).toContain("Reviewer App"); + expect(documentation).toContain("reviewer_app_login_mismatch"); + expect(documentation).toContain("event-bound default-branch commit"); + expect(documentation).toContain("maintenance_already_enabled"); + expect(documentation).toContain("issue #29"); + expect(documentation).toContain("issue #27"); + expect(documentation).toContain("GITHUB_TOKEN"); + expect(documentation).toContain("--apply"); + expect(documentation).toContain("100 records per page"); + expect(documentation).toContain("does not persist unexpected repository names"); + expect(documentation).toContain("does not run `npm ci`"); + }); +}); diff --git a/test/production-coverage-policy.test.ts b/test/production-coverage-policy.test.ts new file mode 100644 index 000000000..f476f8e90 --- /dev/null +++ b/test/production-coverage-policy.test.ts @@ -0,0 +1,24 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +describe("production coverage policy", () => { + it("executes the production coverage gate in every release verification", () => { + const packageJson = JSON.parse(readFileSync("package.json", "utf8")); + + expect(packageJson.scripts.test).toBe("vitest run --coverage"); + expect(packageJson.scripts["release:verify"]).toContain("npm run test"); + expect(packageJson.scripts["release:verify:strict"]).toContain("npm run test"); + }); + + it("covers both the Worker and the production evidence normalizer at 100 percent", () => { + const configuration = readFileSync("vitest.config.ts", "utf8"); + + expect(configuration).toContain('"src/**/*.ts"'); + expect(configuration).toContain( + '"scripts/normalize-commercial-readiness-evidence.mjs"', + ); + for (const metric of ["lines", "branches", "functions", "statements"]) { + expect(configuration).toContain(`${metric}: 100`); + } + }); +}); diff --git a/test/url-parser-defensive-branches.test.ts b/test/url-parser-defensive-branches.test.ts new file mode 100644 index 000000000..d0e9ac0ab --- /dev/null +++ b/test/url-parser-defensive-branches.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { isTrustedGithubApiBase } from "../src/entrypoint"; +import { trustedClientIdentifier } from "../src/rate-limit"; + +function requestWithClientIp(value: string): Request { + return new Request("https://noema.example/exchange", { + headers: { "cf-connecting-ip": value }, + }); +} + +describe("URL parser defensive branches", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("fails closed when the runtime URL parser rejects an otherwise allowlisted origin", () => { + vi.stubGlobal("URL", class RejectingUrlParser { + constructor() { + throw new TypeError("simulated URL parser failure"); + } + }); + + expect(isTrustedGithubApiBase("https://api.github.com")).toBe(false); + }); + + it.each([ + ["missing opening bracket", "2001:db8::1"], + ["missing closing bracket", "[2001:db8::1"], + ["non-IPv6 normalized hostname", "[not-an-ip]"], + ])("rejects a runtime IPv6 hostname with %s", (_case, hostname) => { + const request = requestWithClientIp("2001:db8::1"); + vi.stubGlobal("URL", class StubbedUrlParser { + readonly hostname = hostname; + }); + + expect(trustedClientIdentifier(request)).toBeUndefined(); + }); +}); diff --git a/test/workflow-concurrency-policy.test.ts b/test/workflow-concurrency-policy.test.ts new file mode 100644 index 000000000..68bda8b6b --- /dev/null +++ b/test/workflow-concurrency-policy.test.ts @@ -0,0 +1,34 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const workflowPaths = [ + ["ci", ".github/workflows/ci.yml"], + ["reviewer-ci", ".github/workflows/reviewer-ci.yml"], +] as const; + +describe("pull-request workflow execution policy", () => { + it.each(workflowPaths)( + "cancels superseded %s runs without cancelling a different pull request", + (_name, path) => { + const workflow = readFileSync(path, "utf8"); + + expect(workflow).toContain("concurrency:"); + expect(workflow).toContain( + "${{ github.event.pull_request.number || github.ref }}", + ); + expect(workflow).toContain("cancel-in-progress: true"); + }, + ); + + it.each(workflowPaths)("pins every external action in %s by immutable commit", (_name, path) => { + const workflow = readFileSync(path, "utf8"); + const actionReferences = [...workflow.matchAll(/^\s*uses:\s*([^\s#]+)(?:\s*#.*)?$/gm)].map( + (match) => match[1], + ); + + expect(actionReferences.length).toBeGreaterThan(0); + for (const reference of actionReferences) { + expect(reference).toMatch(/^[^@]+@[0-9a-f]{40}$/); + } + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 05e402174..d14e56998 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,7 +4,10 @@ export default defineConfig({ test: { coverage: { reporter: ["json-summary", "text"], - include: ["src/**/*.ts"], + include: [ + "src/**/*.ts", + "scripts/normalize-commercial-readiness-evidence.mjs", + ], thresholds: { lines: 100, branches: 100,