diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d6f903d8..69bd2438d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,9 @@ name: ci on: pull_request: + types: [opened, synchronize, reopened, edited] + merge_group: + types: [checks_requested] push: branches: - main @@ -17,22 +20,25 @@ jobs: timeout-minutes: 15 permissions: contents: read + pull-requests: read env: GIT_CONFIG_COUNT: "1" GIT_CONFIG_KEY_0: init.defaultBranch GIT_CONFIG_VALUE_0: main + NOEMA_LIVE_PR_BASE_SHA: ${{ github.event.merge_group.base_sha || '' }} steps: - name: checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + ref: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha || github.sha }} fetch-depth: 0 persist-credentials: false - name: verify exact checkout shell: bash env: - NOEMA_EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + NOEMA_EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha || github.sha }} + NOEMA_EXPECTED_BASE_SHA: ${{ github.event.merge_group.base_sha || '' }} run: | set -euo pipefail if [[ ! "$NOEMA_EXPECTED_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then @@ -40,6 +46,16 @@ jobs: exit 1 fi test "$(git rev-parse HEAD)" = "$NOEMA_EXPECTED_HEAD_SHA" + if [ "$GITHUB_EVENT_NAME" = "merge_group" ]; then + if [[ ! "$NOEMA_EXPECTED_BASE_SHA" =~ ^[0-9a-f]{40}$ ]]; then + printf '::error::Invalid merge-group base SHA.\n' + exit 1 + fi + git merge-base --is-ancestor "$NOEMA_EXPECTED_BASE_SHA" "$NOEMA_EXPECTED_HEAD_SHA" + elif [ -n "$NOEMA_EXPECTED_BASE_SHA" ]; then + printf '::error::Unexpected merge-group base SHA outside a merge-group event.\n' + exit 1 + fi - name: setup node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -54,89 +70,28 @@ jobs: test "$(node --version)" = "v24.21.0" test "$(npm --version)" = "11.19.0" - - name: verify live pull-request base before lockfile control + - name: capture live pull-request identity before lockfile control if: github.event_name == 'pull_request' shell: bash env: GH_TOKEN: ${{ github.token }} - NOEMA_PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + NOEMA_PR_NUMBER: ${{ github.event.pull_request.number }} NOEMA_EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - set -euo pipefail - if [[ ! "$NOEMA_EXPECTED_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then - printf '::error::Invalid expected head SHA.\n' - exit 1 - fi - if [ -z "$NOEMA_PR_BASE_REF" ]; then - printf '::error::Pull-request base ref is unavailable.\n' - exit 1 - fi - repository_name="${GITHUB_REPOSITORY#*/}" - resolve_live_base_sha() { - local output="" - local graphql_transient_exhausted="false" - for attempt in 1 2 3; do - if output="$( - gh api graphql \ - -f query='query($owner:String!,$name:String!,$qualifiedName:String!){repository(owner:$owner,name:$name){ref(qualifiedName:$qualifiedName){target{oid}}}}' \ - -F owner="$GITHUB_REPOSITORY_OWNER" \ - -F name="$repository_name" \ - -F qualifiedName="refs/heads/${NOEMA_PR_BASE_REF}" \ - --jq '.data.repository.ref.target.oid' \ - 2>&1 - )"; then - printf '%s\n' "$output" - return 0 - fi - if printf '%s\n' "$output" | grep -Eq '\(HTTP (502|503|504)\)$'; then - if [ "$attempt" -lt 3 ]; then - sleep "$attempt" - continue - fi - graphql_transient_exhausted="true" - break - fi - printf '::error::Live pull-request base resolution failed after attempt %s.\n' "$attempt" >&2 - return 1 - done - if [ "$graphql_transient_exhausted" != "true" ]; then - return 1 - fi - if output="$( - gh api --method GET "repos/${GITHUB_REPOSITORY}/git/ref/heads/${NOEMA_PR_BASE_REF}" \ - --jq '.object.sha' \ - 2>&1 - )"; then - printf '%s\n' "$output" - return 0 - fi - printf '::error::Live pull-request base REST fallback failed.\n' >&2 - return 1 - } - live_base_sha="$(resolve_live_base_sha)" - if [[ ! "$live_base_sha" =~ ^[0-9a-f]{40}$ ]]; then - printf '::error::Live pull-request base ref did not resolve to a full commit SHA.\n' - exit 1 - fi - if ! git merge-base --is-ancestor "$live_base_sha" "$NOEMA_EXPECTED_HEAD_SHA"; then - printf '::error::Pull-request head does not contain the current live base %s.\n' "$live_base_sha" - exit 1 - fi - printf 'NOEMA_LIVE_BASE_SHA=%s\n' "$live_base_sha" >> "$GITHUB_ENV" + run: bash scripts/verify-live-pull-request-identity.sh capture - name: verify lockfile change control - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' || github.event_name == 'merge_group' shell: bash run: | set -euo pipefail - if [[ ! "$NOEMA_LIVE_BASE_SHA" =~ ^[0-9a-f]{40}$ ]]; then + if [[ ! "$NOEMA_LIVE_PR_BASE_SHA" =~ ^[0-9a-f]{40}$ ]]; then printf '::error::Invalid live pull-request base SHA.\n' exit 1 fi base_lock="$RUNNER_TEMP/noema-package-lock-base.json" - git show "${NOEMA_LIVE_BASE_SHA}:package-lock.json" >"$base_lock" + git show "${NOEMA_LIVE_PR_BASE_SHA}:package-lock.json" >"$base_lock" NOEMA_LOCKFILE_BASE_PATH="$base_lock" \ - NOEMA_LOCKFILE_BASE_SHA="$NOEMA_LIVE_BASE_SHA" \ + NOEMA_LOCKFILE_BASE_SHA="$NOEMA_LIVE_PR_BASE_SHA" \ node --input-type=module <<'NODE' import { runLockfileChangeControl } from "./scripts/lockfile-change-control.mjs"; @@ -182,7 +137,7 @@ jobs: if: ${{ !cancelled() && steps.regenerate_lockfile.outcome != 'skipped' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: noema-lockfile-regeneration-${{ github.event.pull_request.head.sha || github.sha }} + name: noema-lockfile-regeneration-${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha || github.sha }} path: | ${{ runner.temp }}/noema-package-lock-regenerated.json ${{ runner.temp }}/noema-package-lock-regeneration.diff @@ -272,72 +227,11 @@ jobs: - name: release acquisition integrity run: npm run acquisition:integrity - - name: refuse pull-request base drift after verification + - name: refuse pull-request identity drift after verification if: github.event_name == 'pull_request' shell: bash env: GH_TOKEN: ${{ github.token }} - NOEMA_PR_BASE_REF: ${{ github.event.pull_request.base.ref }} - run: | - set -euo pipefail - if [[ ! "$NOEMA_LIVE_BASE_SHA" =~ ^[0-9a-f]{40}$ ]]; then - printf '::error::Initial live pull-request base SHA is unavailable.\n' - exit 1 - fi - if [ -z "$NOEMA_PR_BASE_REF" ]; then - printf '::error::Pull-request base ref is unavailable.\n' - exit 1 - fi - repository_name="${GITHUB_REPOSITORY#*/}" - resolve_live_base_sha() { - local output="" - local graphql_transient_exhausted="false" - for attempt in 1 2 3; do - if output="$( - gh api graphql \ - -f query='query($owner:String!,$name:String!,$qualifiedName:String!){repository(owner:$owner,name:$name){ref(qualifiedName:$qualifiedName){target{oid}}}}' \ - -F owner="$GITHUB_REPOSITORY_OWNER" \ - -F name="$repository_name" \ - -F qualifiedName="refs/heads/${NOEMA_PR_BASE_REF}" \ - --jq '.data.repository.ref.target.oid' \ - 2>&1 - )"; then - printf '%s\n' "$output" - return 0 - fi - if printf '%s\n' "$output" | grep -Eq '\(HTTP (502|503|504)\)$'; then - if [ "$attempt" -lt 3 ]; then - sleep "$attempt" - continue - fi - graphql_transient_exhausted="true" - break - fi - printf '::error::Live pull-request base resolution failed after attempt %s.\n' "$attempt" >&2 - return 1 - done - if [ "$graphql_transient_exhausted" != "true" ]; then - return 1 - fi - if output="$( - gh api --method GET "repos/${GITHUB_REPOSITORY}/git/ref/heads/${NOEMA_PR_BASE_REF}" \ - --jq '.object.sha' \ - 2>&1 - )"; then - printf '%s\n' "$output" - return 0 - fi - printf '::error::Live pull-request base REST fallback failed.\n' >&2 - return 1 - } - live_base_sha="$(resolve_live_base_sha)" - if [[ ! "$live_base_sha" =~ ^[0-9a-f]{40}$ ]]; then - printf '::error::Live pull-request base ref did not resolve to a full commit SHA.\n' - exit 1 - fi - if [ "$live_base_sha" != "$NOEMA_LIVE_BASE_SHA" ]; then - printf '::error::Pull-request base branch advanced during verification from %s to %s.\n' \ - "$NOEMA_LIVE_BASE_SHA" "$live_base_sha" - exit 1 - fi - test "$live_base_sha" = "$NOEMA_LIVE_BASE_SHA" \ No newline at end of file + NOEMA_PR_NUMBER: ${{ github.event.pull_request.number }} + NOEMA_EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: bash scripts/verify-live-pull-request-identity.sh check diff --git a/CHANGELOG.md b/CHANGELOG.md index b2ffd51c1..6d3a2615b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,33 @@ - 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 #733 adds the missing `merge_group/checks_requested` application-CI + contract. Merge-queue runs now checkout the exact event `head_sha`, require + the exact event `base_sha` to be a canonical commit ancestor, and execute the + existing lockfile change control plus ordinary release verification on + GitHub's latest-base synthetic merge group. + This is source support, not merge authority by itself: the protected-main + ruleset must require the merge queue and this check before the P0 base-only + invalidation gap can be closed. +- PR #733 follow-up repairs an invalid TypeScript test fixture introduced by + the live base-ref verifier tests. The generated fake `gh` program no longer + nests unescaped template literals inside its enclosing fixture template; + exact API-path and unexpected-invocation checks use ordinary string + concatenation instead. `stripTypeScriptTypes` now parses the exact file and + the direct Node syntax check is green. The repository Vitest suite remains + unclaimed locally because the available Node 24.19.0 runtime is rejected by + the repository's exact Node 24.21.0 `devEngines` contract. +- PR #733 removes stale PR-snapshot authority from each started or re-run + application CI gate. The gate reads only `(head SHA, base ref)` from the live + pull-request endpoint and resolves the current base SHA independently through + the Git-ref endpoint; identity drift or missing exact ancestry fails closed. + The fake `gh` and `git` executables now verify exact methods, endpoints, + projections, and merge-base operands, including fail-closed non-ancestry. + This does not self-invalidate a terminal same-head Check after a later + base-only push because GitHub emits no corresponding PR-head synchronize + event. That P0 observer/merge-authority gap remains open, so the PR stays + Draft / Proposed / merge HOLD pending prerequisite #737, fresh hosted + exact-head Checks, independent approval, and protected-main revalidation. - 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. diff --git a/docs/CONTEXT_MAP.md b/docs/CONTEXT_MAP.md index 00d5e08f5..b50d5d233 100644 --- a/docs/CONTEXT_MAP.md +++ b/docs/CONTEXT_MAP.md @@ -10,7 +10,7 @@ Noema owns an evidence-producing credential and maintenance control plane plus a ### Credential Exchange -Owns GitHub Actions OIDC verification, exact reusable-workflow source identity, replay/rate controls, repository-scoped GitHub App capability minting, and the `/health`, `/ready`, `/exchange` HTTP boundary. Credential evidence is not review, merge, release, deployment, or legal authority. +Owns GitHub Actions OIDC verification, exact reusable-workflow source identity, replay/rate controls, owner identity bound to the GitHub App installation rather than a fixed owner list (ADR-0019), repository-scoped GitHub App capability minting, and the `/health`, `/ready`, `/exchange` HTTP boundary. Credential evidence is not review, merge, release, deployment, or legal authority. ### Maintenance Control diff --git a/docs/adr/0003-exact-revision-and-live-base.md b/docs/adr/0003-exact-revision-and-live-base.md index 05756cc1d..1b4746f80 100644 --- a/docs/adr/0003-exact-revision-and-live-base.md +++ b/docs/adr/0003-exact-revision-and-live-base.md @@ -33,6 +33,16 @@ When a decision depends on current integration ancestry, dependency content, loc A GitHub `refs/pull/*/merge` revision is valid integration evidence. It is not labelled immutable-head evidence unless the specific scanner/test separately proves the PR head it consumed. +For protected-main merge authority, application CI also subscribes to +`merge_group/checks_requested`. It checks out the event's exact synthetic +`head_sha`, validates the event's exact `base_sha`, and requires that base to be +an ancestor of the tested merge-group commit. The same exact base SHA feeds the +existing lockfile change-control comparison. GitHub rebuilds the temporary +merge group from the latest base before required checks authorize a queued +merge. Source support is non-authorizing until the protected-main ruleset +requires the merge queue and the application-CI check; a direct ordinary merge +must not be represented as carrying this base-binding guarantee. + ### Stacked PRs The immediate stack predecessor's live tip is an explicit dependency. A stacked PR is not early-retargeted merely to create a required check if doing so duplicates predecessor changes or corrupts dependency order. @@ -68,6 +78,7 @@ Any movement between decision and write causes abort/re-plan rather than blind r ## Verification - `test/ci-exact-head-contract.test.ts`. +- `test/ci-live-pull-request-identity.test.ts` merge-group head/base binding. - live-base preflight/post-verification and package/lockfile contracts. - stack/base, publisher and Security Scan trigger semantics. - `ARCHITECTURE.md` exact-head/workflow-source invariants. @@ -76,3 +87,10 @@ Any movement between decision and write causes abort/re-plan rather than blind r ## Rationale sources `docs/doctoring/architecture-trust-boundaries.md` records SLSA Source Track and GitHub OIDC primary-source rationale with APA 7 references. + +GitHub. (n.d.). *Events that trigger workflows: merge_group*. Retrieved +October 9, 2026, from +https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#merge_group + +GitHub. (n.d.). *Managing a merge queue*. Retrieved October 9, 2026, from +https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue diff --git a/docs/adr/0019-installation-consent-owner-trust.md b/docs/adr/0019-installation-consent-owner-trust.md new file mode 100644 index 000000000..7076fcd09 --- /dev/null +++ b/docs/adr/0019-installation-consent-owner-trust.md @@ -0,0 +1,71 @@ +# ADR-0019: GitHub App installation is the owner's consent + +- Status: Proposed +- Date: 2026-09-29 +- Owners: Noema Credential Exchange bounded context +- Related: `src/index.ts` (`verifyOidcToken`, `validateRepositoryName`, + `resolveInstallationId`), `test/installation-consent-owner-trust.test.ts` + +## Problem + +The exchange accepted a caller only when the OIDC `repository_owner` equalled +`ALLOWED_REPOSITORY_OWNER` and `repository_owner_id` equalled one hard-coded id, +and it refused any `target_repository` outside that owner. Adding one more +organization meant a code change, a deployment and another fixed value. The +owner rejected that model: Noema reviews repositories in other organizations +(for example `HYOSUNG-ITX-AI-Business-Department`) that should not need an +allowlist edit. + +The fixed owner list was never the real authorization. A GitHub App installation +token can only reach repositories where the App is installed, and only an owner +administrator can install it. The installation is already the owner's consent. + +## Decision + +Keep the workflow identity fixed and derive owner identity from the installation. + +- **Fixed trust anchor.** The OIDC `job_workflow_ref` (or `workflow_ref`) must + still equal the configured central workflow ref exactly, with the + `ALLOWED_WORKFLOW_SHA` pin, issuer, audience, replay and rate controls + unchanged. An arbitrary workflow in any organization still cannot mint. +- **No owner allowlist.** `repository_owner` must be a syntactically valid GitHub + login and `repository_owner_id` a positive numeric id. A well-formed + `repository` claim must belong to `repository_owner`. The id of the central + workflow repository's owner stays pinned, as do the known repository ids. +- **Target rule unchanged.** A caller may request only its own repository; only + the central workflow repository may target another repository. +- **Installation binding.** The installation response must carry an `account` + with a numeric id and a login. The login must equal the target owner. When the + target owner is the caller owner, the account id must equal the OIDC + `repository_owner_id`, which defeats organization-name reuse. The binding is + cached with the installation id and re-checked on every cache hit. A response + without an account fails closed with `502 ERR_GITHUB_API`. + +`ALLOWED_REPOSITORY_OWNER` now only names the central workflow repository's +owner, which readiness still validates. + +## Consequences + +- Any organization that installs the Noema App can have its repositories + reviewed by the central workflow without a Noema change. Tokens are still + scoped to the caller's own repository (or, for the central repository, to the + installed target), so there is no privilege gain across organizations. +- Cost exposure moves to the caller's runner minutes and inference credentials. + A ceiling, if wanted, is a rate-limit setting, not an allowlist. +- Using Noema from a new organization still needs two owner actions outside this + code: the App must be installable there (public App, or owned by that + account), and an organization administrator must install it. +- A deployment that sets `GITHUB_APP_INSTALLATION_ID` pins exchange to that one + installation; multi-organization use requires leaving it unset. +- The central workflow in `ContextualWisdomLab/.github` also hard-codes the + owner in its target checks and App-token steps; loosening those is a separate + change in that repository. + +## Confirmation + +`test/installation-consent-owner-trust.test.ts`: a caller outside +ContextualWisdomLab with a matching installation mints; mismatched installation +account id or login is refused; a missing account fails closed; a foreign caller +cannot target another organization; a non-central workflow ref is refused; the +central repository can target an installed organization; a cached installation +is re-checked against a changed owner id. diff --git a/docs/api-spec.md b/docs/api-spec.md index 1a69a3cc6..deeab42fe 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -63,7 +63,7 @@ `application/json` 요청 body는 UTF-8 wire bytes 기준 최대 **8,192 bytes**이며, 전체 body stream은 읽기 시작 후 **10,000 ms의 절대 wall-clock deadline** 안에 완료되어야 한다. `Content-Length`가 byte 한도를 초과하면 body를 읽지 않고 413으로 거부하며, 길이 헤더가 없거나 신뢰할 수 없는 경우에도 stream을 최대 한도까지만 읽어 chunked 전송 우회를 차단한다. sender가 작은 chunk를 계속 보내더라도 10,000 ms deadline은 재설정되지 않으며, 완료되지 않은 stream은 408로 실패-폐쇄하고 best-effort로 취소한다. 이 body 경계는 downstream distributed rate-limit delegation, OIDC/JWKS 검증, GitHub App private-key 사용, GitHub API 호출 전에 적용된다. Bearer envelope의 bounded structural validation은 body 처리보다 먼저 수행된다. -`target_repository`가 포함되면 문자열이어야 하며, `owner/repository` 형식과 허용된 organization owner를 만족해야 한다. repository-name 세그먼트는 GitHub의 저장소 이름 경계에 맞춰 **1–100자**의 ASCII 영문자·숫자·`.`, `-`, `_`만 허용한다. owner 또는 name 세그먼트가 정확히 `.` 또는 `..`이면 GitHub App private-key 사용과 token 발급 전에 `400 ERR_VALIDATION_INPUT`으로 거부된다. `.github`처럼 점이 포함된 실제 저장소 이름은 허용한다. 객체/배열/null 등 문자열이 아닌 값은 GitHub token 생성 전에 `ERR_VALIDATION_INPUT`으로 거부된다. 호출자는 `ContextualWisdomLab/`만 보내고, 100자를 초과하는 repository name·경로 순회 세그먼트·퍼센트 인코딩된 점·추가 슬래시·백슬래시는 보내지 않는다. 공개 OpenAPI `RepositoryLocator`는 lookahead 없이 RE2-안전한 `allOf`/`not` 패턴으로 같은 길이·문자집합·dot-segment 규칙을 실행하므로, 구매자 도구가 패턴을 컴파일한 뒤 `ContextualWisdomLab/.github`만 보내고 `owner/..` 또는 101자 name은 보내지 않으면 된다. 설계 근거와 APA 7th 참고문헌은 [`docs/doctoring/repository-path-segment-validation.md`](doctoring/repository-path-segment-validation.md)에 있다. +`target_repository`가 포함되면 문자열이어야 하며, `owner/repository` 형식을 만족해야 한다. 소유자는 고정 허용 목록으로 제한하지 않는다. GitHub App이 그 소유자 계정에 설치되어 있어야 하고, 설치 응답의 `account.login`이 대상 소유자와 같아야 하며, 호출자와 같은 소유자라면 `account.id`가 OIDC `repository_owner_id`와 같아야 한다(ADR-0019). repository-name 세그먼트는 GitHub의 저장소 이름 경계에 맞춰 **1–100자**의 ASCII 영문자·숫자·`.`, `-`, `_`만 허용한다. owner 또는 name 세그먼트가 정확히 `.` 또는 `..`이면 GitHub App private-key 사용과 token 발급 전에 `400 ERR_VALIDATION_INPUT`으로 거부된다. `.github`처럼 점이 포함된 실제 저장소 이름은 허용한다. 객체/배열/null 등 문자열이 아닌 값은 GitHub token 생성 전에 `ERR_VALIDATION_INPUT`으로 거부된다. 호출자는 자기 저장소만 보내고(중앙 워크플로 저장소만 다른 저장소를 대상으로 할 수 있다), 100자를 초과하는 repository name·경로 순회 세그먼트·퍼센트 인코딩된 점·추가 슬래시·백슬래시는 보내지 않는다. 공개 OpenAPI `RepositoryLocator`는 lookahead 없이 RE2-안전한 `allOf`/`not` 패턴으로 같은 길이·문자집합·dot-segment 규칙을 실행하므로, 구매자 도구가 패턴을 컴파일한 뒤 `ContextualWisdomLab/.github`만 보내고 `owner/..` 또는 101자 name은 보내지 않으면 된다. 설계 근거와 APA 7th 참고문헌은 [`docs/doctoring/repository-path-segment-validation.md`](doctoring/repository-path-segment-validation.md)에 있다. OIDC workflow trust는 전체 ref 문자열의 exact match 정책을 사용한다. - `job_workflow_ref`가 있으면 이를 우선하고, 없으면 `workflow_ref`를 사용한다. diff --git a/docs/onboarding.md b/docs/onboarding.md index f515bc641..8e3e38125 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -24,7 +24,7 @@ - 공개 변수 등록 - `ALLOWED_ISSUER` (기본: `https://token.actions.githubusercontent.com`) - `ALLOWED_AUDIENCE` (예: `cwl-noema-review`) - - `ALLOWED_REPOSITORY_OWNER` (예: `ContextualWisdomLab`) + - `ALLOWED_REPOSITORY_OWNER` (중앙 워크플로 저장소의 소유자, 예: `ContextualWisdomLab`; 호출 조직을 제한하지 않음, ADR-0019) - `ALLOWED_WORKFLOW_REPOSITORY` (예: `ContextualWisdomLab/.github`) - `ALLOWED_WORKFLOW_REF_PREFIX` (예: `ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main`) - `GITHUB_API_BASE` (기본: `https://api.github.com`) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 23c69c710..aba80f4da 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,53 @@ # Noema Product and Technical Gap Baseline +## PR #733 fixture parse correction (2026-10-07) + +Exact head `539bfe34524ae3a414f58c9a5fbda6e4ad2e5a9a` embedded two +unescaped JavaScript template literals inside the outer TypeScript fixture +template in `test/ci-live-pull-request-identity.test.ts`. Node's +`stripTypeScriptTypes` rejected the file with +`ERR_INVALID_TYPESCRIPT_SYNTAX` before verifier tests could be collected; the +earlier direct-syntax GREEN statement was invalid. + +The smallest repair constructs the generated fake `gh` Git-ref path and +unexpected-invocation diagnostic with ordinary string concatenation. This +preserves the non-vacuous method/endpoint/argument oracle without nested +delimiter authority. Fresh `stripTypeScriptTypes` and direct Node syntax checks +pass. Focused/full Vitest GREEN is not claimed locally because the available +Node 24.19.0 runtime fails the exact Node 24.21.0 `devEngines` admission. + +Status remains **Draft / Proposed / merge HOLD** pending executable hosted +exact-head GREEN, prerequisite #737, the residual post-completion base-only +observer/merge-authority contract, and qualifying independent approval. + +## Proposed live pull-request base-tip authority (2026-10-07) + +PR #733 previously treated the pull-request endpoint's `.base.sha` field as the +current tip of `.base.ref`. A same-head re-run after the base branch advanced +could therefore validate the older snapshot. The test doubles were also vacuous: +they returned fixtures without verifying the GitHub calls or merge-base operands. + +The proposed repair reads only live `(head SHA, base ref)` from the +pull-request endpoint and resolves that ref independently through the Git-ref +endpoint at the capture and final gates. Its executable doubles validate exact +API commands, exact ancestry operands, and fail-closed non-ancestry. The original +script fails the focused RED harness; repaired shell and syntax checks pass +locally. + +This closes stale `.base.sha` admission inside a started or re-run gate. The +candidate now also supplies the missing `merge_group/checks_requested` source +contract: application CI checks out the merge group's exact synthetic head and +requires its canonical event base SHA to be an ancestor before running release +verification; that same base SHA drives lockfile change control. GitHub's merge +queue rebuilds this group from the latest base, +covering the post-completion base-only window that a PR `synchronize` event does +not observe. Status remains **Draft / Proposed / merge HOLD** because source +support is not external merge authority. The protected-main ruleset must require +the merge queue and this check; fresh hosted merge-group/exact-head Checks, +qualifying independent approval, prerequisite #737 ordinary merge, and +protected-main revalidation remain required. Local GREEN is not release or +merge authority. + ## Proposed released gateway endpoint admission (2026-10-02) Open PR #737 proposes an exact-membership gate for every token-bearing @@ -132,6 +180,8 @@ Protected #654, #655, #657, #659, #661, #663, #665, #668, #670, #673, #676, #678 These integrations are source/test/documentation authority only. They do not create new provider/model routing, foreign outbound or quarantine authority, release/deployment authority, or production performance evidence. `deployed heap/p95 evidence remains separate`: representative deployed workload, allocation/GC observation and applicable p95 must still be measured against an immutable deployed release before buyer-facing runtime-performance claims are admissible. +PR #733 exact predecessor head `65d3036f18e0b11e281d44245e690a3c550c7461` exposed a review-evidence TOCTOU defect. Event `32296185938` retargeted the PR while run `36939785675` still admitted stale event base data. Exact head `42da900e1fdcd2e436b8865d069771c03f2d2b02` improved this to a PR endpoint lookup but still trusted `.base.sha`, and its fake `gh` ignored all arguments. The new RED corpus requires exact PR and Git-ref API calls, exact merge-base operands, and fail-closed non-ancestry. GREEN resolves current base-tip authority independently for each run. Post-completion base-only push invalidation remains open and is not merge-authority complete; this candidate remains Draft / Proposed pending a trusted observer or merge-queue contract, fresh hosted exact-head Checks, independent approval, prerequisite #737 integration, ordinary merge, and immutable release. + ## Evidence and merge rules Review resolution, CI, reviewer-ci, required Security, image/SBOM/provenance, branch ancestry, release는 separate evidence classes다. Every source mutation/restack invalidates predecessor workflow evidence. `queued`, `pending`, `in_progress`, `skipped`, `cancelled`, stale 또는 absent-required evidence는 passing이 아니다. @@ -152,6 +202,7 @@ PR 0은 useful work를 닫아 제조하지 않는다. Open lane은 normal merge | 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 | Pull-request review identity TOCTOU | event snapshot 또는 PR `.base.sha`가 live base-tip으로 오인되고, terminal Check 뒤 base-only push가 자동 무효화되지 않을 위험 | active Draft PR #733 | exact PR head/ref + Git-ref tip gate, non-vacuous API/ancestry doubles, `merge_group/checks_requested` exact synthetic-head/base-ancestry source contract integrated; protected-main merge-queue/ruleset evidence open | repaired exact head + required merge queue/check + fresh full hosted CI/reviewer/security/image Checks + independent approval + ordinary merge | atomic non-force publish 후 exact-head/merge-group Checks 수집; protected-main ruleset에서 merge queue와 application CI를 required로 설정·검증; #737 전에는 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/scripts/verify-live-pull-request-identity.sh b/scripts/verify-live-pull-request-identity.sh new file mode 100644 index 000000000..fd8574160 --- /dev/null +++ b/scripts/verify-live-pull-request-identity.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +set -euo pipefail + +mode="${1:-}" +if [ "$mode" != "capture" ] && [ "$mode" != "check" ]; then + printf '::error::Expected capture or check mode.\n' >&2 + exit 1 +fi +if [[ ! "${GITHUB_REPOSITORY:-}" =~ ^[^/]+/[^/]+$ ]]; then + printf '::error::GitHub repository identity is invalid.\n' >&2 + exit 1 +fi +if [[ ! "${NOEMA_PR_NUMBER:-}" =~ ^[1-9][0-9]*$ ]]; then + printf '::error::Pull-request number is invalid.\n' >&2 + exit 1 +fi +if [[ ! "${NOEMA_EXPECTED_HEAD_SHA:-}" =~ ^[0-9a-f]{40}$ ]]; then + printf '::error::Expected pull-request head is invalid.\n' >&2 + exit 1 +fi + +read_live_pull_request() { + local attempt output + for attempt in 1 2 3; do + if output="$( + gh api --method GET "repos/${GITHUB_REPOSITORY}/pulls/${NOEMA_PR_NUMBER}" \ + --jq '[.head.sha,.base.ref] | @tsv' \ + 2>&1 + )"; then + printf '%s\n' "$output" + return 0 + fi + if printf '%s\n' "$output" | grep -Eq '\(HTTP (502|503|504)\)$' && [ "$attempt" -lt 3 ]; then + sleep "$attempt" + continue + fi + printf '::error::Live pull-request lookup failed after attempt %s.\n' "$attempt" >&2 + return 1 + done +} + +read_live_base_sha() { + local base_ref="$1" attempt output + for attempt in 1 2 3; do + if output="$( + gh api --method GET "repos/${GITHUB_REPOSITORY}/git/ref/heads/${base_ref}" \ + --jq '.object.sha' \ + 2>&1 + )"; then + printf '%s\n' "$output" + return 0 + fi + if printf '%s\n' "$output" | grep -Eq '\(HTTP (502|503|504)\)$' && [ "$attempt" -lt 3 ]; then + sleep "$attempt" + continue + fi + printf '::error::Live pull-request base lookup failed after attempt %s.\n' "$attempt" >&2 + return 1 + done +} + +pull_request_identity="$(read_live_pull_request)" +IFS=$'\t' read -r live_head_sha live_base_ref extra <<< "$pull_request_identity" +if [[ ! "$live_head_sha" =~ ^[0-9a-f]{40}$ ]]; then + printf '::error::Live pull-request head is invalid.\n' >&2 + exit 1 +fi +if ! git check-ref-format --branch "$live_base_ref" >/dev/null 2>&1 || [ -n "${extra:-}" ]; then + printf '::error::Live pull-request base ref is invalid.\n' >&2 + exit 1 +fi +live_base_sha="$(read_live_base_sha "$live_base_ref")" +if [[ ! "$live_base_sha" =~ ^[0-9a-f]{40}$ ]]; then + printf '::error::Live pull-request base SHA is invalid.\n' >&2 + exit 1 +fi + +if [ "$mode" = "capture" ]; then + if [ "$live_head_sha" != "$NOEMA_EXPECTED_HEAD_SHA" ]; then + printf '::error::Live pull-request head does not match the reviewed head.\n' >&2 + exit 1 + fi + if ! git merge-base --is-ancestor "$live_base_sha" "$live_head_sha"; then + printf '::error::Pull-request head does not contain the live base %s@%s.\n' \ + "$live_base_ref" "$live_base_sha" >&2 + exit 1 + fi + if [ -z "${GITHUB_ENV:-}" ]; then + printf '::error::GitHub environment file is unavailable.\n' >&2 + exit 1 + fi + { + printf 'NOEMA_LIVE_PR_HEAD_SHA=%s\n' "$live_head_sha" + printf 'NOEMA_LIVE_PR_BASE_REF=%s\n' "$live_base_ref" + printf 'NOEMA_LIVE_PR_BASE_SHA=%s\n' "$live_base_sha" + } >> "$GITHUB_ENV" + exit 0 +fi + +if [[ ! "${NOEMA_LIVE_PR_HEAD_SHA:-}" =~ ^[0-9a-f]{40}$ ]] || \ + [[ ! "${NOEMA_LIVE_PR_BASE_SHA:-}" =~ ^[0-9a-f]{40}$ ]] || \ + ! git check-ref-format --branch "${NOEMA_LIVE_PR_BASE_REF:-}" >/dev/null 2>&1; then + printf '::error::Captured pull-request identity is invalid.\n' >&2 + exit 1 +fi +if [ "$NOEMA_LIVE_PR_HEAD_SHA" != "$NOEMA_EXPECTED_HEAD_SHA" ]; then + printf '::error::Captured pull-request head does not match the reviewed head.\n' >&2 + exit 1 +fi +if [ "$live_head_sha" != "$NOEMA_LIVE_PR_HEAD_SHA" ] || \ + [ "$live_base_ref" != "$NOEMA_LIVE_PR_BASE_REF" ] || \ + [ "$live_base_sha" != "$NOEMA_LIVE_PR_BASE_SHA" ]; then + printf '::error::Pull-request identity changed during verification: head %s -> %s, base %s@%s -> %s@%s.\n' \ + "$NOEMA_LIVE_PR_HEAD_SHA" "$live_head_sha" \ + "$NOEMA_LIVE_PR_BASE_REF" "$NOEMA_LIVE_PR_BASE_SHA" \ + "$live_base_ref" "$live_base_sha" >&2 + exit 1 +fi diff --git a/src/index.ts b/src/index.ts index 4e9ab35d4..696ebeeab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -101,11 +101,24 @@ type InstallationIdResolution = { source: "configured" | "cache" | "discovery"; }; +/** Installation id plus the account that consented by installing the App. */ +type InstallationBinding = { + id: string; + accountId: string; + accountLogin: string; +}; + +/** OIDC caller owner identity used to bind a same-owner installation. */ +type CallerOwner = { + login: string; + id: string; +}; + const rateLimitBuckets = new Map(); const rateLimitWindowMs = 60_000; const maxLocalRateLimitBuckets = 10_000; let oidcKeysCache: TimedCache | undefined; -const installationIdCache = new Map>(); +const installationIdCache = new Map>(); class ApiError extends Error { constructor( @@ -138,6 +151,8 @@ const githubInstallationTokenPattern = /^[\x21-\x7e]{1,4096}$/; const githubAppPrivateKeyPattern = /^-----BEGIN PRIVATE KEY-----\r?\n([A-Za-z0-9+/=\r\n]+)\r?\n-----END PRIVATE KEY-----$/; const oidcJsonMediaTypePattern = /^[ \t]*application\/json[ \t]*(?:;[ \t]*charset[ \t]*=[ \t]*utf-8[ \t]*)?$/i; const expectedRepositoryOwnerId = "295022177"; +const githubOwnerLoginPattern = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/; +const githubNumericIdPattern = /^[1-9]\d{0,18}$/; const expectedRepositoryIds = new Map([ ["ContextualWisdomLab/noema", "1285107801"], ["ContextualWisdomLab/.github", "1274066402"], @@ -637,8 +652,27 @@ async function verifyGithubOidcJwt(token: string, env: Env): Promise if (typeof payload.sub !== "string" || payload.sub.length === 0) { throw new ApiError("ERR_AUTH_INVALID", 401, "OIDC subject claim is invalid"); } - if (payload.repository_owner !== env.ALLOWED_REPOSITORY_OWNER) throw new ApiError("ERR_REPO_NOT_ALLOWED", 403, "OIDC repository owner is not allowed"); - if (payload.repository_owner_id !== expectedRepositoryOwnerId) { + // Caller owners are not a fixed list: the App installation is the owner's consent + // and is bound to this identity when the token is minted (ADR 0019). + if (typeof payload.repository_owner !== "string" || !githubOwnerLoginPattern.test(payload.repository_owner)) { + throw new ApiError("ERR_REPO_NOT_ALLOWED", 403, "OIDC repository owner is not allowed"); + } + // A well-formed repository claim must belong to the owner claim; malformed names + // are left to the later target_repository syntax validation. + if ( + typeof payload.repository === "string" + && payload.repository.includes("/") + && payload.repository.split("/", 1)[0].toLowerCase() !== payload.repository_owner.toLowerCase() + ) { + throw new ApiError("ERR_REPO_NOT_ALLOWED", 403, "OIDC repository owner is not allowed"); + } + if (typeof payload.repository_owner_id !== "string" || !githubNumericIdPattern.test(payload.repository_owner_id)) { + throw new ApiError("ERR_REPO_NOT_ALLOWED", 403, "OIDC repository owner identity is not allowed"); + } + if ( + payload.repository_owner === workflowRepositoryOwner(env) + && payload.repository_owner_id !== expectedRepositoryOwnerId + ) { throw new ApiError("ERR_REPO_NOT_ALLOWED", 403, "OIDC repository owner identity is not allowed"); } const expectedRepositoryId = payload.repository ? expectedRepositoryIds.get(payload.repository) : undefined; @@ -739,7 +773,12 @@ async function verifyGithubOidcJwt(token: string, env: Env): Promise } } -function validateRepositoryName(repository: string, env: Env): string { +/** Owner of the central workflow repository; the only owner whose id is pinned. */ +function workflowRepositoryOwner(env: Env): string { + return env.ALLOWED_WORKFLOW_REPOSITORY.split("/", 1)[0]; +} + +function validateRepositoryName(repository: string): string { if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]{1,100}$/.test(repository)) { throw new ApiError("ERR_VALIDATION_INPUT", 400, "target_repository is not a valid owner/name repository"); } @@ -747,7 +786,7 @@ function validateRepositoryName(repository: string, env: Env): string { if (/^\.{1,2}$/.test(owner) || /^\.{1,2}$/.test(name)) { throw new ApiError("ERR_VALIDATION_INPUT", 400, "target_repository is not a valid owner/name repository"); } - if (owner !== env.ALLOWED_REPOSITORY_OWNER) { + if (!githubOwnerLoginPattern.test(owner)) { throw new ApiError("ERR_REPO_NOT_ALLOWED", 403, "target_repository owner is not allowed"); } return repository; @@ -827,10 +866,25 @@ async function githubJson( return value as Record; } +/** + * Require the installation account to be the target owner and, for a same-owner caller, + * the OIDC owner identity. Runs on every resolution, including cache hits. + */ +function requireInstallationConsent(binding: InstallationBinding, repository: string, caller: CallerOwner): void { + const targetOwner = repository.split("/", 1)[0].toLowerCase(); + if (binding.accountLogin.toLowerCase() !== targetOwner) { + throw new ApiError("ERR_REPO_NOT_ALLOWED", 403, "GitHub App installation account does not own target_repository"); + } + if (targetOwner === caller.login.toLowerCase() && binding.accountId !== caller.id) { + throw new ApiError("ERR_REPO_NOT_ALLOWED", 403, "GitHub App installation account does not match the OIDC repository owner"); + } +} + async function resolveInstallationId( appJwt: string, repository: string, env: Env, + caller: CallerOwner, ): Promise { const configuredInstallationId = env.GITHUB_APP_INSTALLATION_ID; if (configuredInstallationId) { @@ -846,7 +900,8 @@ async function resolveInstallationId( const cacheKey = `${env.GITHUB_API_BASE}:${env.GITHUB_APP_ID}:${repository}`; const cached = configuredInstallationId ? undefined : installationIdCache.get(cacheKey); if (cached && cached.expiresAtMs > now) { - return { value: cached.value, source: "cache" }; + requireInstallationConsent(cached.value, repository, caller); + return { value: cached.value.id, source: "cache" }; } if (cached) { installationIdCache.delete(cacheKey); @@ -866,6 +921,23 @@ async function resolveInstallationId( throw new ApiError("ERR_GITHUB_API", 502, "GitHub API returned invalid installation response"); } const installationId = String(installation.id); + const account = installation.account; + if ( + typeof account !== "object" || account === null + || typeof (account as Record).id !== "number" + || !Number.isSafeInteger((account as Record).id) + || ((account as Record).id as number) <= 0 + || typeof (account as Record).login !== "string" + || !githubOwnerLoginPattern.test((account as Record).login as string) + ) { + throw new ApiError("ERR_GITHUB_API", 502, "GitHub API returned invalid installation response"); + } + const binding: InstallationBinding = { + id: installationId, + accountId: String((account as Record).id), + accountLogin: (account as Record).login as string, + }; + requireInstallationConsent(binding, repository, caller); if (configuredInstallationId && installationId !== configuredInstallationId) { throw new ApiError( "ERR_GITHUB_INSTALLATION", @@ -875,7 +947,7 @@ async function resolveInstallationId( } if (!configuredInstallationId) { installationIdCache.set(cacheKey, { - value: installationId, + value: binding, expiresAtMs: now + configuredTtlMs(env.NOEMA_INSTALLATION_CACHE_TTL_SECONDS, 600, 3600), }); } @@ -885,9 +957,9 @@ async function resolveInstallationId( }; } -async function createInstallationToken(repository: string, env: Env): Promise { +async function createInstallationToken(repository: string, env: Env, caller: CallerOwner): Promise { const appJwt = await createGitHubAppJwt(env); - let installationResolution = await resolveInstallationId(appJwt, repository, env); + let installationResolution = await resolveInstallationId(appJwt, repository, env, caller); let installationId = installationResolution.value; const mintInstallationToken = (id: string) => githubJson(`/app/installations/${id}/access_tokens`, { method: "POST", @@ -907,7 +979,7 @@ async function createInstallationToken(repository: string, env: Env): Promise { expectPinnedApplicationToolchain(workflow); }); - it("binds lockfile verification to one fresh live base instead of the historical PR base snapshot", () => { + it("binds lockfile verification to one fresh live PR identity instead of event snapshots", () => { const workflow = readWorkflow(workflowPaths[0]); expect(workflow).toContain( - 'git merge-base --is-ancestor "$live_base_sha" "$NOEMA_EXPECTED_HEAD_SHA"', + "bash scripts/verify-live-pull-request-identity.sh capture", ); expect(workflow).toContain( - 'printf \'NOEMA_LIVE_BASE_SHA=%s\\n\' "$live_base_sha" >> "$GITHUB_ENV"', + "bash scripts/verify-live-pull-request-identity.sh check", ); expect(workflow).toContain( - 'git show "${NOEMA_LIVE_BASE_SHA}:package-lock.json" >"$base_lock"', + 'git show "${NOEMA_LIVE_PR_BASE_SHA}:package-lock.json" >"$base_lock"', + ); + expect(workflow).toContain('NOEMA_LOCKFILE_BASE_SHA="$NOEMA_LIVE_PR_BASE_SHA"'); + expect(workflow).toContain( + "NOEMA_PR_NUMBER: ${{ github.event.pull_request.number }}", ); - expect(workflow).toContain('NOEMA_LOCKFILE_BASE_SHA="$NOEMA_LIVE_BASE_SHA"'); - expect(workflow).toContain('if [ "$live_base_sha" != "$NOEMA_LIVE_BASE_SHA" ]; then'); expect(workflow).not.toContain( 'NOEMA_PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}', ); - expect(workflow).not.toContain('test "$live_base_sha" = "$NOEMA_PR_BASE_SHA"'); + expect(workflow).not.toContain( + 'NOEMA_PR_BASE_REF: ${{ github.event.pull_request.base.ref }}', + ); }); it("keeps each release verifier visible as its own failing CI boundary with bounded test diagnostics", () => { diff --git a/test/ci-live-base-rest-fallback.test.ts b/test/ci-live-base-rest-fallback.test.ts index 0d42116b9..5b19415a9 100644 --- a/test/ci-live-base-rest-fallback.test.ts +++ b/test/ci-live-base-rest-fallback.test.ts @@ -1,17 +1,24 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -describe("CI live-base resolver availability", () => { - it("falls back to the live Git ref REST endpoint after bounded GraphQL availability exhaustion", () => { - const workflow = readFileSync(".github/workflows/ci.yml", "utf8"); +describe("CI live pull-request identity availability", () => { + it("reads live PR head/ref and resolves the current base branch tip", () => { + const gate = readFileSync( + "scripts/verify-live-pull-request-identity.sh", + "utf8", + ); - expect( - workflow.match( - /gh api --method GET "repos\/\$\{GITHUB_REPOSITORY\}\/git\/ref\/heads\/\$\{NOEMA_PR_BASE_REF\}"/g, - ), - ).toHaveLength(2); - expect(workflow.match(/--jq '\.object\.sha'/g)).toHaveLength(2); - expect(workflow.match(/Live pull-request base REST fallback failed\./g)).toHaveLength(2); - expect(workflow).not.toContain("github.event.pull_request.base.sha"); + expect(gate).toContain( + 'gh api --method GET "repos/${GITHUB_REPOSITORY}/pulls/${NOEMA_PR_NUMBER}"', + ); + expect(gate).toContain("--jq '[.head.sha,.base.ref] | @tsv'"); + expect(gate).toContain( + 'gh api --method GET "repos/${GITHUB_REPOSITORY}/git/ref/heads/${base_ref}"', + ); + expect(gate).toContain("--jq '.object.sha'"); + expect(gate).not.toContain(".base.sha"); + expect(gate).toContain("for attempt in 1 2 3; do"); + expect(gate).toContain("\\(HTTP (502|503|504)\\)$"); + expect(gate).not.toContain("gh api graphql"); }); }); diff --git a/test/ci-live-pull-request-identity.test.ts b/test/ci-live-pull-request-identity.test.ts new file mode 100644 index 000000000..9c35c2255 --- /dev/null +++ b/test/ci-live-pull-request-identity.test.ts @@ -0,0 +1,288 @@ +import { + chmodSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { afterEach, describe, expect, it } from "vitest"; + +const temporaryDirectories: string[] = []; +const expectedHeadSha = "a".repeat(40); +const initialBaseSha = "b".repeat(40); + +interface PullRequestIdentity { + readonly headSha: string; + readonly baseRef: string; + readonly baseSha: string; +} + +function prepareIdentityGate( + identities: readonly PullRequestIdentity[], + options: { readonly isAncestor?: boolean } = {}, +) { + const directory = mkdtempSync(join(tmpdir(), "noema-live-pr-identity-")); + temporaryDirectories.push(directory); + const counterPath = join(directory, "gh-count"); + const environmentPath = join(directory, "github-env"); + const fakeGhPath = join(directory, "gh"); + const fakeGitPath = join(directory, "git"); + + writeFileSync( + fakeGhPath, + `#!/usr/bin/env node +const fs = require("node:fs"); +const identities = ${JSON.stringify(identities)}; +const counterPath = ${JSON.stringify(counterPath)}; +let count = 0; +try { count = Number(fs.readFileSync(counterPath, "utf8")); } catch {} +const identity = identities[Math.min(Math.floor(count / 2), identities.length - 1)]; +const expected = count % 2 === 0 + ? [ + "api", + "--method", + "GET", + "repos/ContextualWisdomLab/noema/pulls/733", + "--jq", + "[.head.sha,.base.ref] | @tsv", + ] + : [ + "api", + "--method", + "GET", + "repos/ContextualWisdomLab/noema/git/ref/heads/" + identity.baseRef, + "--jq", + ".object.sha", + ]; +if (JSON.stringify(process.argv.slice(2)) !== JSON.stringify(expected)) { + process.stderr.write("unexpected gh invocation: " + JSON.stringify(process.argv.slice(2)) + "\\n"); + process.exit(41); +} +fs.writeFileSync(counterPath, String(count + 1)); +process.stdout.write( + count % 2 === 0 + ? [identity.headSha, identity.baseRef].join("\\t") + "\\n" + : identity.baseSha + "\\n", +); +`, + { encoding: "utf8", mode: 0o700 }, + ); + writeFileSync( + fakeGitPath, + `#!/usr/bin/env node +const args = process.argv.slice(2); +if (args[0] === "check-ref-format" && args[1] === "--branch") { + process.exit(args[2] && !args[2].includes("..") ? 0 : 1); +} +if ( + args[0] === "merge-base" + && args[1] === "--is-ancestor" + && args[2] === ${JSON.stringify(identities[0]?.baseSha)} + && args[3] === ${JSON.stringify(expectedHeadSha)} +) { + process.exit(${options.isAncestor === false ? 1 : 0}); +} +process.stderr.write("unexpected git invocation: " + args.join(" ")); +process.exit(2); +`, + { encoding: "utf8", mode: 0o700 }, + ); + chmodSync(fakeGhPath, 0o700); + chmodSync(fakeGitPath, 0o700); + + const baseEnvironment = { + ...process.env, + PATH: `${directory}:${process.env.PATH ?? ""}`, + GITHUB_REPOSITORY: "ContextualWisdomLab/noema", + GITHUB_ENV: environmentPath, + NOEMA_PR_NUMBER: "733", + NOEMA_EXPECTED_HEAD_SHA: expectedHeadSha, + }; + const run = (mode: "capture" | "check", environment = baseEnvironment) => + spawnSync("bash", ["scripts/verify-live-pull-request-identity.sh", mode], { + cwd: process.cwd(), + encoding: "utf8", + env: environment, + }); + + return { baseEnvironment, environmentPath, run }; +} + +function exportedEnvironment(path: string): Record { + return Object.fromEntries( + readFileSync(path, "utf8") + .trim() + .split("\n") + .map((line) => { + const separator = line.indexOf("="); + return [line.slice(0, separator), line.slice(separator + 1)]; + }), + ); +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("CI live pull-request identity gate", () => { + it("captures and rechecks one stable live head and base identity", () => { + const identity = { + headSha: expectedHeadSha, + baseRef: "fix/prerequisite", + baseSha: initialBaseSha, + }; + const gate = prepareIdentityGate([identity, identity]); + + const capture = gate.run("capture"); + expect(capture.status, capture.stderr).toBe(0); + const exported = exportedEnvironment(gate.environmentPath); + expect(exported).toEqual({ + NOEMA_LIVE_PR_HEAD_SHA: expectedHeadSha, + NOEMA_LIVE_PR_BASE_REF: "fix/prerequisite", + NOEMA_LIVE_PR_BASE_SHA: initialBaseSha, + }); + + const check = gate.run("check", { ...gate.baseEnvironment, ...exported }); + expect(check.status, check.stderr).toBe(0); + }); + + it("rejects a base retarget even when the pull-request head is unchanged", () => { + const gate = prepareIdentityGate([ + { headSha: expectedHeadSha, baseRef: "main", baseSha: initialBaseSha }, + { headSha: expectedHeadSha, baseRef: "fix/prerequisite", baseSha: "c".repeat(40) }, + ]); + + const capture = gate.run("capture"); + expect(capture.status, capture.stderr).toBe(0); + const check = gate.run("check", { + ...gate.baseEnvironment, + ...exportedEnvironment(gate.environmentPath), + }); + + expect(check.status).not.toBe(0); + expect(check.stderr).toContain("Pull-request identity changed during verification"); + }); + + it("rejects a base-ref-only change", () => { + const gate = prepareIdentityGate([ + { headSha: expectedHeadSha, baseRef: "main", baseSha: initialBaseSha }, + { headSha: expectedHeadSha, baseRef: "fix/prerequisite", baseSha: initialBaseSha }, + ]); + + expect(gate.run("capture").status).toBe(0); + const check = gate.run("check", { + ...gate.baseEnvironment, + ...exportedEnvironment(gate.environmentPath), + }); + + expect(check.status).not.toBe(0); + expect(check.stderr).toContain("Pull-request identity changed during verification"); + }); + + it("resolves the base branch tip independently of stale pull-request metadata", () => { + const gate = prepareIdentityGate([ + { headSha: expectedHeadSha, baseRef: "main", baseSha: initialBaseSha }, + { headSha: expectedHeadSha, baseRef: "main", baseSha: "c".repeat(40) }, + ]); + + expect(gate.run("capture").status).toBe(0); + const check = gate.run("check", { + ...gate.baseEnvironment, + ...exportedEnvironment(gate.environmentPath), + }); + + expect(check.status).not.toBe(0); + expect(check.stderr).toContain("Pull-request identity changed during verification"); + }); + + it("rejects a head change even when the base identity is unchanged", () => { + const gate = prepareIdentityGate([ + { headSha: expectedHeadSha, baseRef: "main", baseSha: initialBaseSha }, + { headSha: "d".repeat(40), baseRef: "main", baseSha: initialBaseSha }, + ]); + + const capture = gate.run("capture"); + expect(capture.status, capture.stderr).toBe(0); + const check = gate.run("check", { + ...gate.baseEnvironment, + ...exportedEnvironment(gate.environmentPath), + }); + + expect(check.status).not.toBe(0); + expect(check.stderr).toContain("Pull-request identity changed during verification"); + }); + + it("fails closed on malformed live identity fields", () => { + const gate = prepareIdentityGate([ + { headSha: "not-a-sha", baseRef: "main", baseSha: initialBaseSha }, + ]); + + const capture = gate.run("capture"); + + expect(capture.status).not.toBe(0); + expect(capture.stderr).toContain("Live pull-request head is invalid"); + }); + + it("fails closed when the exact live base is not an ancestor of the reviewed head", () => { + const gate = prepareIdentityGate([ + { + headSha: expectedHeadSha, + baseRef: "fix/prerequisite", + baseSha: initialBaseSha, + }, + ], { isAncestor: false }); + + const capture = gate.run("capture"); + + expect(capture.status).not.toBe(0); + expect(capture.stderr).toContain("does not contain the live base"); + }); + + it("binds both workflow gates to the live PR API rather than event base fields", () => { + const workflow = readFileSync(".github/workflows/ci.yml", "utf8"); + + expect(workflow).toContain("types: [opened, synchronize, reopened, edited]"); + expect(workflow).toMatch(/permissions:\n\s+contents: read\n\s+pull-requests: read/); + expect(workflow).toContain( + "NOEMA_PR_NUMBER: ${{ github.event.pull_request.number }}", + ); + expect(workflow).toContain( + "bash scripts/verify-live-pull-request-identity.sh capture", + ); + expect(workflow).toContain( + "bash scripts/verify-live-pull-request-identity.sh check", + ); + expect(workflow).not.toContain( + "NOEMA_PR_BASE_REF: ${{ github.event.pull_request.base.ref }}", + ); + const gate = readFileSync("scripts/verify-live-pull-request-identity.sh", "utf8"); + expect(gate).toContain("--jq '[.head.sha,.base.ref] | @tsv'"); + expect(gate).toContain( + 'gh api --method GET "repos/${GITHUB_REPOSITORY}/git/ref/heads/${base_ref}"', + ); + expect(gate).toContain("--jq '.object.sha'"); + expect(gate).not.toContain(".base.sha"); + }); + + it("runs required CI for merge-queue groups and binds the exact synthetic head to its base", () => { + const workflow = readFileSync(".github/workflows/ci.yml", "utf8"); + + expect(workflow).toMatch(/merge_group:\n\s+types: \[checks_requested\]/); + expect(workflow).toContain("github.event.merge_group.head_sha"); + expect(workflow).toContain("github.event.merge_group.base_sha"); + expect(workflow).toContain( + "if: github.event_name == 'pull_request' || github.event_name == 'merge_group'", + ); + expect(workflow).toContain( + "NOEMA_LIVE_PR_BASE_SHA: ${{ github.event.merge_group.base_sha || '' }}", + ); + expect(workflow).toContain( + 'git merge-base --is-ancestor "$NOEMA_EXPECTED_BASE_SHA" "$NOEMA_EXPECTED_HEAD_SHA"', + ); + }); +}); diff --git a/test/credential-request-helper-coverage.test.ts b/test/credential-request-helper-coverage.test.ts index 12297fcd7..880447cee 100644 --- a/test/credential-request-helper-coverage.test.ts +++ b/test/credential-request-helper-coverage.test.ts @@ -257,7 +257,7 @@ describe("credential request helper coverage through the public worker", () => { ).toHaveLength(0); }); - it("rejects a syntactically valid repository owned outside the configured organization", async () => { + it("mints nothing for an outside organization that has not installed the App", async () => { const { token, jwk } = await createSignedJwt("ContextualWisdomLab/.github"); const upstream = mockOidcDiscovery(jwk); @@ -274,14 +274,11 @@ describe("credential request helper coverage through the public worker", () => { env, ); - expect(response.status).toBe(403); - await expect(response.json()).resolves.toMatchObject({ - ok: false, - error_code: "ERR_REPO_NOT_ALLOWED", - message: "target_repository owner is not allowed", - }); + // The owner is no longer a fixed allowlist; the missing installation is the refusal. + expect(response.status).not.toBe(200); + await expect(response.json()).resolves.toMatchObject({ ok: false }); expect( - upstream.mock.calls.filter(([input]) => String(input).startsWith("https://api.github.com/")), + upstream.mock.calls.filter(([input]) => String(input).includes("/access_tokens")), ).toHaveLength(0); }); diff --git a/test/exchange-success-path-coverage.test.ts b/test/exchange-success-path-coverage.test.ts index 0f2b14873..1ff5a698a 100644 --- a/test/exchange-success-path-coverage.test.ts +++ b/test/exchange-success-path-coverage.test.ts @@ -140,7 +140,7 @@ describe("exchange success-path coverage through the public worker", () => { return Response.json({ keys: [jwk] }); } if (url === "https://api.github.com/repos/ContextualWisdomLab/noema/installation") { - return Response.json({ id: 12345 }); + return Response.json({ id: 12345, account: { id: 295022177, login: "ContextualWisdomLab" } }); } if (url === "https://api.github.com/app/installations/12345/access_tokens") { return Response.json({ @@ -230,7 +230,7 @@ describe("exchange success-path coverage through the public worker", () => { return Response.json({ keys: [jwk] }); } if (url === "https://api.github.com/repos/ContextualWisdomLab/noema/installation") { - return Response.json({ id: 12345 }); + return Response.json({ id: 12345, account: { id: 295022177, login: "ContextualWisdomLab" } }); } if (url === "https://api.github.com/app/installations/12345/access_tokens") { return Response.json({ diff --git a/test/external-json-locked-reader.test.ts b/test/external-json-locked-reader.test.ts index 3beb8aa8a..056c22e91 100644 --- a/test/external-json-locked-reader.test.ts +++ b/test/external-json-locked-reader.test.ts @@ -222,7 +222,7 @@ describe("external JSON response reader acquisition", () => { return Response.json({ keys: [jwk] }); } if (url === "https://api.github.com/repos/ContextualWisdomLab/noema/installation") { - const installation = Response.json({ id: 12345 }); + const installation = Response.json({ id: 12345, account: { id: 295022177, login: "ContextualWisdomLab" } }); heldReader = installation.body!.getReader(); return installation; } diff --git a/test/github-api-content-type-authority.test.ts b/test/github-api-content-type-authority.test.ts index d85e05069..e29f1f128 100644 --- a/test/github-api-content-type-authority.test.ts +++ b/test/github-api-content-type-authority.test.ts @@ -140,7 +140,7 @@ async function exchangeWithInstallationTokenResponse( return Response.json({ keys: [jwk] }); } if (url === "https://api.github.com/repos/ContextualWisdomLab/.github/installation") { - return Response.json({ id: 92345 }); + return Response.json({ id: 92345, account: { id: 295022177, login: "ContextualWisdomLab" } }); } if (url === "https://api.github.com/app/installations/92345/access_tokens") { return installationTokenResponse; diff --git a/test/github-api-malformed-json.test.ts b/test/github-api-malformed-json.test.ts index 92ccb420b..9def03896 100644 --- a/test/github-api-malformed-json.test.ts +++ b/test/github-api-malformed-json.test.ts @@ -136,7 +136,7 @@ async function exchangeWith( env.GITHUB_APP_INSTALLATION_ID && url === `https://api.github.com/repos/${targetRepository}/installation` ) { - return Response.json({ id: Number(env.GITHUB_APP_INSTALLATION_ID) }); + return Response.json({ id: Number(env.GITHUB_APP_INSTALLATION_ID) , account: { id: 295022177, login: "ContextualWisdomLab" } }); } return githubHandler(url); }); diff --git a/test/github-app-explicit-installation-id-validation.test.ts b/test/github-app-explicit-installation-id-validation.test.ts index d780f2e13..9b15d6958 100644 --- a/test/github-app-explicit-installation-id-validation.test.ts +++ b/test/github-app-explicit-installation-id-validation.test.ts @@ -177,7 +177,7 @@ async function exchangeWithRepositoryBoundInstallationId( githubApiCalls.push(url); if (url === repositoryInstallationUrl) { - return Response.json({ id: discoveredInstallationId }, { status: 200 }); + return Response.json({ id: discoveredInstallationId , account: { id: 295022177, login: "ContextualWisdomLab" } }, { status: 200 }); } if (url === installationTokenUrl) { return Response.json({ diff --git a/test/github-app-runtime-coverage.test.ts b/test/github-app-runtime-coverage.test.ts index fd24f2187..d36122af4 100644 --- a/test/github-app-runtime-coverage.test.ts +++ b/test/github-app-runtime-coverage.test.ts @@ -137,7 +137,7 @@ async function exchange( env.GITHUB_APP_INSTALLATION_ID && url === `https://api.github.com/repos/${targetRepository}/installation` ) { - return Response.json({ id: Number(env.GITHUB_APP_INSTALLATION_ID) }); + return Response.json({ id: Number(env.GITHUB_APP_INSTALLATION_ID) , account: { id: 295022177, login: "ContextualWisdomLab" } }); } return githubHandler(url, init); }); @@ -310,7 +310,7 @@ describe("GitHub App runtime coverage through the public exchange boundary", () const githubHandler = (url: string) => { if (url === `https://api.github.com/repos/${targetRepository}/installation`) { installationLookups += 1; - return Response.json({ id: 42345 }); + return Response.json({ id: 42345, account: { id: 295022177, login: "ContextualWisdomLab" } }); } if (url === "https://api.github.com/app/installations/42345/access_tokens") { tokenCalls += 1; @@ -337,7 +337,7 @@ describe("GitHub App runtime coverage through the public exchange boundary", () const githubHandler = (url: string) => { if (url === `https://api.github.com/repos/${targetRepository}/installation`) { installationLookups += 1; - return Response.json({ id: 52345 }); + return Response.json({ id: 52345, account: { id: 295022177, login: "ContextualWisdomLab" } }); } if (url === "https://api.github.com/app/installations/52345/access_tokens") { return successfulTokenResponse(); diff --git a/test/github-installation-cache-refresh.test.ts b/test/github-installation-cache-refresh.test.ts index 06c9b01b5..169eecef4 100644 --- a/test/github-installation-cache-refresh.test.ts +++ b/test/github-installation-cache-refresh.test.ts @@ -156,6 +156,7 @@ describe("GitHub App installation cache refresh", () => { id: installationLookups === 1 ? Number(firstInstallationId) : Number(replacementInstallationId), + account: { id: 295022177, login: "ContextualWisdomLab" }, }); } const mintMatch = url.match(/\/app\/installations\/(\d+)\/access_tokens$/); @@ -227,6 +228,7 @@ describe("GitHub App installation cache refresh", () => { id: installationLookups === 1 ? Number(freshInstallationId) : Number(replacementId), + account: { id: 295022177, login: "ContextualWisdomLab" }, }); } const mintMatch = url.match(/\/app\/installations\/(\d+)\/access_tokens$/); diff --git a/test/github-installation-expiry-defensive-coverage.test.ts b/test/github-installation-expiry-defensive-coverage.test.ts index ea87b75ed..023dd7158 100644 --- a/test/github-installation-expiry-defensive-coverage.test.ts +++ b/test/github-installation-expiry-defensive-coverage.test.ts @@ -77,7 +77,7 @@ async function exchangeWithTokenResponse(tokenBody: unknown, clientIp: string): const url = String(input); if (url.endsWith("/.well-known/openid-configuration")) return Response.json({ jwks_uri: "https://token.actions.githubusercontent.com/.well-known/jwks" }); if (url.endsWith("/.well-known/jwks")) return Response.json({ keys: [{ ...oidcPublicJwk, kid, kty: "RSA" }] }); - if (url === "https://api.github.com/repos/ContextualWisdomLab/noema/installation") return Response.json({ id: 92345 }); + if (url === "https://api.github.com/repos/ContextualWisdomLab/noema/installation") return Response.json({ id: 92345, account: { id: 295022177, login: "ContextualWisdomLab" } }); if (url === "https://api.github.com/app/installations/92345/access_tokens") return Response.json(tokenBody, { status: 201 }); return new Response("unexpected", { status: 500 }); }); diff --git a/test/github-installation-token-expiry-calendar-integrity.test.ts b/test/github-installation-token-expiry-calendar-integrity.test.ts index 282d9dc97..cc797d23f 100644 --- a/test/github-installation-token-expiry-calendar-integrity.test.ts +++ b/test/github-installation-token-expiry-calendar-integrity.test.ts @@ -133,7 +133,7 @@ describe("GitHub installation-token expiry calendar integrity", () => { return Response.json({ keys: [jwk] }); } if (url === "https://api.github.com/repos/ContextualWisdomLab/expiry-calendar/installation") { - return Response.json({ id: 92345 }); + return Response.json({ id: 92345, account: { id: 295022177, login: "ContextualWisdomLab" } }); } if (url === "https://api.github.com/app/installations/92345/access_tokens") { return Response.json({ diff --git a/test/github-installation-token-stateless-format.test.ts b/test/github-installation-token-stateless-format.test.ts index 3a5c9547b..9a26a58b4 100644 --- a/test/github-installation-token-stateless-format.test.ts +++ b/test/github-installation-token-stateless-format.test.ts @@ -163,7 +163,7 @@ describe("GitHub installation-token stateless format", () => { }); } if (url === `https://api.github.com/repos/${targetRepository}/installation`) { - return Response.json({ id: Number(installationId) }); + return Response.json({ id: Number(installationId) , account: { id: 295022177, login: "ContextualWisdomLab" } }); } if (url === `https://api.github.com/app/installations/${installationId}/access_tokens`) { return Response.json({ diff --git a/test/installation-consent-owner-trust.test.ts b/test/installation-consent-owner-trust.test.ts new file mode 100644 index 000000000..e7553ea6a --- /dev/null +++ b/test/installation-consent-owner-trust.test.ts @@ -0,0 +1,268 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Env } from "../src/index"; + +// Caller and target owners are not a fixed list: the GitHub App installation is +// the owner's consent, and the exact central workflow ref stays the trust anchor. +const configuredRef = + "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main"; +const configuredWorkflowSha = "a".repeat(40); +const foreignOwner = "HYOSUNG-ITX-AI-Business-Department"; +const foreignOwnerId = "900000001"; +const foreignRepository = `${foreignOwner}/llm-gateway-console`; + +function acceptingReplayGuard(): DurableObjectNamespace { + return { + idFromName: (name: string) => ({ toString: () => name }) as DurableObjectId, + get: () => + ({ + fetch: async (_input: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? "{}")); + return Response.json( + { accepted: true, expires_at_epoch_seconds: body.expires_at_epoch_seconds }, + { status: 201 }, + ); + }, + }) as unknown as DurableObjectStub, + } as unknown as DurableObjectNamespace; +} + +const baseEnv: Env = { + ALLOWED_ISSUER: "https://token.actions.githubusercontent.com", + ALLOWED_AUDIENCE: "cwl-noema-review", + ALLOWED_REPOSITORY_OWNER: "ContextualWisdomLab", + ALLOWED_WORKFLOW_REPOSITORY: "ContextualWisdomLab/.github", + ALLOWED_WORKFLOW_REF_PREFIX: configuredRef, + ALLOWED_WORKFLOW_SHA: configuredWorkflowSha, + GITHUB_API_BASE: "https://api.github.com", + GITHUB_APP_ID: "1", + GITHUB_APP_PRIVATE_KEY_PEM: "replaced-per-test", + NOEMA_RATE_LIMIT_PER_MINUTE: "1000", + NOEMA_OIDC_REPLAY_GUARD: acceptingReplayGuard(), +}; + +const segment = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); + +async function rsaKeyPair(): Promise { + return (await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + )) as CryptoKeyPair; +} + +async function signedOidc(claims: Record) { + const keys = await rsaKeyPair(); + const kid = `owner-trust-${crypto.randomUUID()}`; + const now = Math.floor(Date.now() / 1000); + const header = segment({ alg: "RS256", kid, typ: "JWT" }); + const body = segment({ + iss: baseEnv.ALLOWED_ISSUER, + aud: baseEnv.ALLOWED_AUDIENCE, + job_workflow_ref: configuredRef, + job_workflow_sha: configuredWorkflowSha, + jti: crypto.randomUUID(), + exp: now + 300, + nbf: now - 30, + iat: now - 30, + ...claims, + }); + const signature = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + keys.privateKey, + new TextEncoder().encode(`${header}.${body}`), + ); + const jwk = await crypto.subtle.exportKey("jwk", keys.publicKey); + return { + token: `${header}.${body}.${Buffer.from(signature).toString("base64url")}`, + jwk: { ...jwk, kid, kty: "RSA" }, + }; +} + +async function appPrivateKeyPem(): Promise { + const keys = await rsaKeyPair(); + const der = Buffer.from(await crypto.subtle.exportKey("pkcs8", keys.privateKey)).toString("base64"); + return `-----BEGIN PRIVATE KEY-----\n${der.match(/.{1,64}/g)?.join("\n")}\n-----END PRIVATE KEY-----`; +} + +type Installation = Record; + +async function exchange(options: { + claims: Record; + target?: string; + installation: Installation; + reuseModule?: boolean; +}) { + if (!options.reuseModule) vi.resetModules(); + const { default: worker } = await import("../src/index"); + const { token, jwk } = await signedOidc(options.claims); + const target = options.target ?? String(options.claims.repository); + const expiresAt = new Date(Date.now() + 60 * 60_000).toISOString(); + vi.restoreAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + if (url.endsWith("/.well-known/openid-configuration")) { + return Response.json({ jwks_uri: "https://token.actions.githubusercontent.com/.well-known/jwks" }); + } + if (url.endsWith("/.well-known/jwks")) return Response.json({ keys: [jwk] }); + if (url === `https://api.github.com/repos/${target}/installation`) { + return Response.json(options.installation); + } + if (url === "https://api.github.com/app/installations/777/access_tokens") { + return Response.json({ token: "ghs_owner_trust", expires_at: expiresAt }, { status: 201 }); + } + return new Response("unexpected egress", { status: 500 }); + }); + const response = await worker.fetch( + new Request("https://noema.example/exchange", { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + "cf-connecting-ip": "203.0.113.77", + }, + body: JSON.stringify(options.target ? { target_repository: options.target } : {}), + }), + { ...baseEnv, GITHUB_APP_PRIVATE_KEY_PEM: await appPrivateKeyPem() }, + ); + return { status: response.status, body: (await response.json()) as Record }; +} + +const foreignCaller = { + repository_owner: foreignOwner, + repository_owner_id: foreignOwnerId, + repository: foreignRepository, + repository_id: "900000002", + sub: `repo:${foreignRepository}:ref:refs/heads/develop`, +}; +const foreignInstallation = { id: 777, account: { id: Number(foreignOwnerId), login: foreignOwner } }; + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); +}); + +describe("installation consent replaces the fixed owner allowlist", () => { + + it("rejects an OIDC owner login outside GitHub's login grammar", async () => { + const invalidOwner = "invalid_owner"; + const result = await exchange({ + claims: { + ...foreignCaller, + repository_owner: invalidOwner, + repository: `${invalidOwner}/llm-gateway-console`, + }, + installation: { + id: 777, + account: { id: Number(foreignOwnerId), login: invalidOwner }, + }, + }); + expect(result.status).toBe(403); + expect(result.body).toMatchObject({ error_code: "ERR_REPO_NOT_ALLOWED" }); + }); + + it("rejects a central target whose owner is outside GitHub's login grammar", async () => { + const result = await exchange({ + claims: { + repository_owner: "ContextualWisdomLab", + repository_owner_id: "295022177", + repository: "ContextualWisdomLab/.github", + repository_id: "1274066402", + sub: "repo:ContextualWisdomLab/.github:ref:refs/heads/main", + }, + target: "invalid_owner/repository", + installation: { + id: 777, + account: { id: 900000001, login: "invalid_owner" }, + }, + }); + expect(result.status).toBe(403); + expect(result.body).toMatchObject({ error_code: "ERR_REPO_NOT_ALLOWED" }); + }); + + it("mints for a caller outside ContextualWisdomLab when its owner installed the App", async () => { + const result = await exchange({ claims: foreignCaller, installation: foreignInstallation }); + expect(result.status).toBe(200); + expect(result.body).toMatchObject({ ok: true, data: { repository: foreignRepository } }); + }); + + it("rejects an installation whose account id differs from the OIDC owner id", async () => { + const result = await exchange({ + claims: foreignCaller, + installation: { id: 777, account: { id: 900000099, login: foreignOwner } }, + }); + expect(result.status).toBe(403); + expect(result.body).toMatchObject({ error_code: "ERR_REPO_NOT_ALLOWED" }); + }); + + it("rejects an installation whose account login differs from the target owner", async () => { + const result = await exchange({ + claims: foreignCaller, + installation: { id: 777, account: { id: Number(foreignOwnerId), login: "someone-else" } }, + }); + expect(result.status).toBe(403); + expect(result.body).toMatchObject({ error_code: "ERR_REPO_NOT_ALLOWED" }); + }); + + it("fails closed when the installation response has no account", async () => { + const result = await exchange({ claims: foreignCaller, installation: { id: 777 } }); + expect(result.status).toBe(502); + expect(result.body).toMatchObject({ ok: false, error_code: "ERR_GITHUB_API" }); + }); + + it("still rejects a foreign caller requesting another organization's repository", async () => { + const result = await exchange({ + claims: foreignCaller, + target: "ContextualWisdomLab/noema", + installation: { id: 777, account: { id: 295022177, login: "ContextualWisdomLab" } }, + }); + expect(result.status).toBe(403); + expect(result.body).toMatchObject({ error_code: "ERR_REPO_NOT_ALLOWED" }); + }); + + it("still rejects a foreign caller whose workflow ref is not the central workflow", async () => { + const result = await exchange({ + claims: { + ...foreignCaller, + job_workflow_ref: `${foreignRepository}/.github/workflows/noema-review.yml@refs/heads/main`, + }, + installation: foreignInstallation, + }); + expect(result.status).toBe(403); + expect(result.body).toMatchObject({ error_code: "ERR_WORKFLOW_NOT_ALLOWED" }); + }); + + it("re-checks the owner binding on an installation cache hit", async () => { + const first = await exchange({ claims: foreignCaller, installation: foreignInstallation }); + expect(first.status).toBe(200); + const second = await exchange({ + claims: { ...foreignCaller, repository_owner_id: "900000099" }, + // A fresh lookup would fail with 502 on this shape; 403 proves the cached binding was re-checked. + installation: { id: 777 }, + reuseModule: true, + }); + expect(second.status).toBe(403); + expect(second.body).toMatchObject({ error_code: "ERR_REPO_NOT_ALLOWED" }); + }); + + it("lets the central workflow repository target an organization that installed the App", async () => { + const result = await exchange({ + claims: { + repository_owner: "ContextualWisdomLab", + repository_owner_id: "295022177", + repository: "ContextualWisdomLab/.github", + repository_id: "1274066402", + sub: "repo:ContextualWisdomLab/.github:ref:refs/heads/main", + }, + target: foreignRepository, + installation: foreignInstallation, + }); + expect(result.status).toBe(200); + expect(result.body).toMatchObject({ ok: true, data: { repository: foreignRepository } }); + }); +}); diff --git a/test/oidc-verification-residual-coverage.test.ts b/test/oidc-verification-residual-coverage.test.ts index 7c271cc1d..4d21ee0e6 100644 --- a/test/oidc-verification-residual-coverage.test.ts +++ b/test/oidc-verification-residual-coverage.test.ts @@ -226,7 +226,7 @@ describe("OIDC verification residual coverage", () => { }); }); - it("rejects a token from a different repository owner", async () => { + it("rejects a token whose owner claim does not own its repository claim", async () => { const claims = baseClaims(); claims.repository_owner = "OtherOwner"; const { response } = await exchange(await signedJwt(claims)); diff --git a/test/outbound-fetch-header-authority.test.ts b/test/outbound-fetch-header-authority.test.ts index aa70cff3c..91450ba95 100644 --- a/test/outbound-fetch-header-authority.test.ts +++ b/test/outbound-fetch-header-authority.test.ts @@ -38,7 +38,7 @@ describe("outbound header authority", () => { }); it("accepts the exact production-reviewed GitHub API header set", async () => { - const rawFetch = vi.fn(async () => Response.json({ id: 12345 })); + const rawFetch = vi.fn(async () => Response.json({ id: 12345, account: { id: 295022177, login: "ContextualWisdomLab" } })); const wrapped = createFailClosedFetch(rawFetch); const response = await wrapped( diff --git a/test/package-manager-predecessor-integration.test.ts b/test/package-manager-predecessor-integration.test.ts index 71ba94e79..8e2cc8a3a 100644 --- a/test/package-manager-predecessor-integration.test.ts +++ b/test/package-manager-predecessor-integration.test.ts @@ -47,9 +47,9 @@ describe("deterministic package-manager work integrated after the nanoid predece }); it("combines live-base lockfile control with the predecessor explicit install flags", () => { - expect(ciWorkflow).toContain("name: verify live pull-request base before lockfile control"); + expect(ciWorkflow).toContain("name: capture live pull-request identity before lockfile control"); expect(ciWorkflow).toContain("name: verify lockfile change control"); - expect(ciWorkflow).toContain("name: refuse pull-request base drift after verification"); + expect(ciWorkflow).toContain("name: refuse pull-request identity drift after verification"); expect(ciWorkflow).toContain("npm ci --legacy-peer-deps=false --install-links=false"); }); -}); \ No newline at end of file +}); diff --git a/test/package-manager-reproducibility.test.ts b/test/package-manager-reproducibility.test.ts index 6adf778f8..98ae4d815 100644 --- a/test/package-manager-reproducibility.test.ts +++ b/test/package-manager-reproducibility.test.ts @@ -166,54 +166,50 @@ describe("package-manager reproducibility contract", () => { expect(ciWorkflow).toContain('test "$(git rev-parse HEAD)" = "$NOEMA_EXPECTED_HEAD_SHA"'); }); - it("validates the fresh live pull-request base as exactly forty lowercase hexadecimal characters", () => { - const shaGate = ciWorkflow.indexOf('if [[ ! "$live_base_sha" =~ ^[0-9a-f]{40}$ ]]; then'); - const exportLiveBase = ciWorkflow.indexOf( - "printf 'NOEMA_LIVE_BASE_SHA=%s\\n' \"$live_base_sha\" >> \"$GITHUB_ENV\"", + it("validates the captured live pull-request base before reading its lockfile", () => { + const identityGate = ciWorkflow.indexOf( + "bash scripts/verify-live-pull-request-identity.sh capture", ); const lockfileGuard = ciWorkflow.indexOf( - 'if [[ ! "$NOEMA_LIVE_BASE_SHA" =~ ^[0-9a-f]{40}$ ]]; then', + 'if [[ ! "$NOEMA_LIVE_PR_BASE_SHA" =~ ^[0-9a-f]{40}$ ]]; then', ); const baseRead = ciWorkflow.indexOf( - 'git show "${NOEMA_LIVE_BASE_SHA}:package-lock.json"', + 'git show "${NOEMA_LIVE_PR_BASE_SHA}:package-lock.json"', ); - expect(shaGate).toBeGreaterThan(-1); - expect(exportLiveBase).toBeGreaterThan(shaGate); - expect(lockfileGuard).toBeGreaterThan(exportLiveBase); + expect(identityGate).toBeGreaterThan(-1); + expect(lockfileGuard).toBeGreaterThan(identityGate); expect(baseRead).toBeGreaterThan(lockfileGuard); - expect(ciWorkflow).toContain("printf '::error::Live pull-request base ref did not resolve to a full commit SHA.\\n'"); expect(ciWorkflow).toContain("printf '::error::Invalid live pull-request base SHA.\\n'"); expect(ciWorkflow).not.toContain( "[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]", ); }); - it("binds lockfile validation to one fresh live base and refuses base movement during verification", () => { - const beforeGate = ciWorkflow.indexOf("name: verify live pull-request base before lockfile control"); + it("binds lockfile validation to one fresh live PR identity and refuses identity movement", () => { + const beforeGate = ciWorkflow.indexOf("name: capture live pull-request identity before lockfile control"); const lockfileGate = ciWorkflow.indexOf("name: verify lockfile change control"); const releaseStart = ciWorkflow.indexOf("name: release typecheck"); const releaseEnd = ciWorkflow.indexOf("name: release acquisition integrity"); - const afterGate = ciWorkflow.indexOf("name: refuse pull-request base drift after verification"); + const afterGate = ciWorkflow.indexOf("name: refuse pull-request identity drift after verification"); expect(beforeGate).toBeGreaterThan(-1); expect(lockfileGate).toBeGreaterThan(beforeGate); expect(releaseStart).toBeGreaterThan(lockfileGate); expect(releaseEnd).toBeGreaterThan(releaseStart); expect(afterGate).toBeGreaterThan(releaseEnd); - expect(ciWorkflow).toContain("NOEMA_PR_BASE_REF: ${{ github.event.pull_request.base.ref }}"); + expect(ciWorkflow).toContain("NOEMA_PR_NUMBER: ${{ github.event.pull_request.number }}"); expect(ciWorkflow).toContain( - 'git merge-base --is-ancestor "$live_base_sha" "$NOEMA_EXPECTED_HEAD_SHA"', + "bash scripts/verify-live-pull-request-identity.sh capture", ); expect(ciWorkflow).toContain( - 'printf \'NOEMA_LIVE_BASE_SHA=%s\\n\' "$live_base_sha" >> "$GITHUB_ENV"', + "bash scripts/verify-live-pull-request-identity.sh check", ); - expect(ciWorkflow.match(/gh api graphql/g)?.length).toBeGreaterThanOrEqual(2); - expect(ciWorkflow.match(/ref\(qualifiedName:\$qualifiedName\)\{target\{oid\}\}/g)?.length).toBeGreaterThanOrEqual(2); - expect(ciWorkflow).toContain('if [ "$live_base_sha" != "$NOEMA_LIVE_BASE_SHA" ]; then'); - expect(ciWorkflow).toContain('test "$live_base_sha" = "$NOEMA_LIVE_BASE_SHA"'); expect(ciWorkflow).not.toContain( "NOEMA_PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}", ); + expect(ciWorkflow).not.toContain( + "NOEMA_PR_BASE_REF: ${{ github.event.pull_request.base.ref }}", + ); }); }); diff --git a/test/package-manager-review-contract.test.ts b/test/package-manager-review-contract.test.ts index ed6fe8a48..5b08aad84 100644 --- a/test/package-manager-review-contract.test.ts +++ b/test/package-manager-review-contract.test.ts @@ -110,7 +110,7 @@ describe("package-manager review contracts", () => { "install", ); const guard = step.indexOf( - 'if [[ ! "$NOEMA_LIVE_BASE_SHA" =~ ^[0-9a-f]{40}$ ]]; then', + 'if [[ ! "$NOEMA_LIVE_PR_BASE_SHA" =~ ^[0-9a-f]{40}$ ]]; then', ); const error = step.indexOf( "printf '::error::Invalid live pull-request base SHA.\\n'", @@ -118,7 +118,7 @@ describe("package-manager review contracts", () => { ); const exit = step.indexOf("exit 1", error); const baseRead = step.indexOf( - 'git show "${NOEMA_LIVE_BASE_SHA}:package-lock.json"', + 'git show "${NOEMA_LIVE_PR_BASE_SHA}:package-lock.json"', exit, ); diff --git a/test/replay-before-token-mint.test.ts b/test/replay-before-token-mint.test.ts index ccec33a99..19ed7e545 100644 --- a/test/replay-before-token-mint.test.ts +++ b/test/replay-before-token-mint.test.ts @@ -130,7 +130,7 @@ describe("verified OIDC replay claim ordering", () => { return Response.json({ keys: [jwk] }); } if (url === "https://api.github.com/repos/ContextualWisdomLab/noema/installation") { - return Response.json({ id: 12345 }); + return Response.json({ id: 12345, account: { id: 295022177, login: "ContextualWisdomLab" } }); } if (url === "https://api.github.com/app/installations/12345/access_tokens") { return Response.json({ @@ -232,7 +232,7 @@ describe("verified OIDC replay claim ordering", () => { } if (url === "https://api.github.com/repos/ContextualWisdomLab/noema/installation") { orderedOperations.push("installation-lookup"); - return Response.json({ id: 12345 }); + return Response.json({ id: 12345, account: { id: 295022177, login: "ContextualWisdomLab" } }); } if (url === "https://api.github.com/app/installations/12345/access_tokens") { orderedOperations.push(`token-mint:${init?.method || "GET"}`); diff --git a/test/replay-request-core-coverage.test.ts b/test/replay-request-core-coverage.test.ts index 3ea61bd85..33c77d2fc 100644 --- a/test/replay-request-core-coverage.test.ts +++ b/test/replay-request-core-coverage.test.ts @@ -112,7 +112,7 @@ function installOidcFetch(jwk: JsonWebKey, env: Env, installationToken = false) return Response.json({ keys: [jwk] }); } if (installationToken && url === `${env.GITHUB_API_BASE}/repos/ContextualWisdomLab/noema/installation`) { - return Response.json({ id: 12345 }); + return Response.json({ id: 12345, account: { id: 295022177, login: "ContextualWisdomLab" } }); } if (installationToken && url === `${env.GITHUB_API_BASE}/app/installations/12345/access_tokens`) { return Response.json({ diff --git a/test/worker.test.ts b/test/worker.test.ts index a2223c670..1cd21c8c7 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -423,7 +423,7 @@ describe("Noema worker", () => { return Response.json({ keys: [jwk] }); } if (url === "https://api.github.com/repos/ContextualWisdomLab/noema/installation") { - return Response.json({ id: 12345 }); + return Response.json({ id: 12345, account: { id: 295022177, login: "ContextualWisdomLab" } }); } if (url === "https://api.github.com/app/installations/12345/access_tokens") { return Response.json({ @@ -514,7 +514,7 @@ describe("Noema worker", () => { return Response.json({ keys: [jwk] }); } if (url === "https://api.github.com/repos/ContextualWisdomLab/noema/installation") { - return Response.json({ id: 12345 }); + return Response.json({ id: 12345, account: { id: 295022177, login: "ContextualWisdomLab" } }); } if (url === "https://api.github.com/app/installations/12345/access_tokens") { return Response.json({ diff --git a/test/workflow-readiness.test.ts b/test/workflow-readiness.test.ts index 69bc430ba..42f3b741d 100644 --- a/test/workflow-readiness.test.ts +++ b/test/workflow-readiness.test.ts @@ -130,15 +130,14 @@ describe("deployment workflow readiness gates", () => { expect(workflow).toContain("if: always()"); }); - it("bounds live-base GitHub retries to transient availability failures", () => { - const workflow = readFileSync(".github/workflows/ci.yml", "utf8"); + it("bounds live-identity GitHub retries to transient availability failures", () => { + const workflow = readFileSync("scripts/verify-live-pull-request-identity.sh", "utf8"); expect(workflow.match(/for attempt in 1 2 3; do/g)).toHaveLength(2); expect(workflow.match(/grep -Eq '\\\(HTTP \(502\|503\|504\)\\\)\$'/g)).toHaveLength(2); expect(workflow.match(/sleep "\$attempt"/g)).toHaveLength(2); - expect(workflow.match(/Live pull-request base resolution failed after attempt/g)).toHaveLength(2); + expect(workflow.match(/Live pull-request (?:base )?lookup failed after attempt/g)).toHaveLength(2); }); - it("runs the mandatory reviewer gate on every pull request", () => { const workflow = readFileSync(".github/workflows/reviewer-ci.yml", "utf8"); @@ -148,4 +147,4 @@ describe("deployment workflow readiness gates", () => { expect(workflow).toContain("test (100% line+branch coverage gate)"); expect(workflow).toContain("docstring coverage (100% gate)"); }); -}); \ No newline at end of file +});