diff --git a/.github/workflows/codeql-pr.yml b/.github/workflows/codeql-pr.yml index e9aaf3ece8..95f1ecc288 100644 --- a/.github/workflows/codeql-pr.yml +++ b/.github/workflows/codeql-pr.yml @@ -6,8 +6,8 @@ # runner, then one coordinator POSTs repository_dispatch to # codeql-scan-dispatch.yml (native, unrestricted, in # ContextualWisdomLab/.github) with the remaining language matrix. The -# handler publishes codeql-dispatch/ and reruns only that exact -# failed job. On rerun the shard reads the terminal status once. Design: +# handler publishes a base/run/source-bound codeql-dispatch receipt and reruns +# only that exact failed job. On rerun the shard reads the terminal status once. Design: # docs/adr/0025-codeql-required-workflow-dispatch-architecture.md. The # merge-preview scan (analyze-merge) is required nowhere (PR #1766) and was # dropped, not migrated. @@ -164,7 +164,7 @@ jobs: steps: - name: Read current-head CodeQL dispatch verdict # Shards never dispatch. They re-check the live head, consume an - # authenticated codeql-dispatch/ verdict when one exists, + # authenticated base/run/source-bound CodeQL verdict when one exists, # and otherwise fail pending so the runner is released. One # coordinator job POSTs the remaining language matrix after every # shard has a job id. @@ -183,6 +183,7 @@ jobs: live_pr="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")" live_head="$(printf '%s' "$live_pr" | jq -r '.head.sha // empty')" live_base="$(printf '%s' "$live_pr" | jq -r '.base.sha // empty')" + live_merge="$(printf '%s' "$live_pr" | jq -r '.merge_commit_sha // empty')" live_state="$(printf '%s' "$live_pr" | jq -r 'if (.state | type) == "string" then .state else empty end')" if ! [[ "$PR_HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ && "$live_head" =~ ^[0-9a-fA-F]{40}$ ]] || [[ "$live_state" != "open" && "$live_state" != "closed" ]]; then echo "::error::Could not validate live pull request state before CodeQL dispatch." @@ -207,8 +208,9 @@ jobs: echo "verdict=obsolete" >>"$GITHUB_OUTPUT" exit 0 fi - if ! [[ "$live_base" =~ ^[0-9a-fA-F]{40}$ ]]; then - echo "::error::Could not validate live pull request base SHA before CodeQL verdict read." + if ! [[ "$live_base" =~ ^[0-9a-fA-F]{40}$ ]] || + ! [[ "$live_merge" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "::error::Could not validate live pull request base/source SHA before CodeQL verdict read." exit 1 fi if ! [[ "$REQUIRED_RUN_ID" =~ ^[1-9][0-9]*$ ]]; then @@ -217,13 +219,17 @@ jobs: fi statuses="$(gh api "repos/${TARGET_REPOSITORY}/commits/${PR_HEAD_SHA}/statuses")" - verdict_state="$(printf '%s' "$statuses" | jq -r --arg ctx "codeql-dispatch/${LANGUAGE}" ' + expected_context="codeql-dispatch/${LANGUAGE}/${live_base}" + expected_description="cwl1;h=${PR_HEAD_SHA};w=codeql-scan-dispatch;r=${REQUIRED_RUN_ID};s=${live_merge}" + verdict_state="$(printf '%s' "$statuses" | jq -r --arg ctx "$expected_context" --arg description "$expected_description" ' [ .[] | select(.context == $ctx) + | select(.description == $description) | select( (.creator.login // "" | ascii_downcase) as $creator | $creator == "opencode-agent" or $creator == "opencode-agent[bot]" + or $creator == "cwl-noema-review" or $creator == "cwl-noema-review[bot]" ) ] | first // {} | .state // empty @@ -236,7 +242,7 @@ jobs: ;; esac - expected_title="CodeQL Scan Dispatch ${TARGET_REPOSITORY}#${PR_NUMBER}@${PR_HEAD_SHA}/${live_base}/${REQUIRED_RUN_ID}" + expected_title="CodeQL Scan Dispatch ${TARGET_REPOSITORY}#${PR_NUMBER}@${PR_HEAD_SHA}/${live_base}/${REQUIRED_RUN_ID}/${live_merge}" expected_job="CodeQL dispatch scan (${LANGUAGE})" # A dispatch bound to this required run cannot predate its creation. required_created_at="$(gh api "repos/${TARGET_REPOSITORY}/actions/runs/${REQUIRED_RUN_ID}" --jq .created_at)" @@ -266,11 +272,20 @@ jobs: gate_conclusion="$(printf '%s' "$dispatch_job" | jq -r ' (.steps[]? | select(.name == "Enforce CodeQL Medium+ SARIF gate") | .conclusion) // empty ')" + ghas_identity_conclusion="$(printf '%s' "$dispatch_job" | jq -r ' + (.steps[]? | select(.name == "Verify GHAS base/head CodeQL configuration identity") | .conclusion) // empty + ')" + sarif_upload_conclusion="$(printf '%s' "$dispatch_job" | jq -r ' + (.steps[]? | select(.name == "Preserve CodeQL SARIF evidence") | .conclusion) // empty + ')" case "$gate_conclusion" in success) - echo "verdict=success" >>"$GITHUB_OUTPUT" - echo "Found completed CodeQL dispatch scan gate for ${LANGUAGE}: success." - exit 0 + if [ "$ghas_identity_conclusion" = "success" ] && + [ "$sarif_upload_conclusion" = "success" ]; then + echo "verdict=success" >>"$GITHUB_OUTPUT" + echo "Found completed CodeQL dispatch proof for ${LANGUAGE}: gate, GHAS identity, and SARIF evidence succeeded." + exit 0 + fi ;; failure|cancelled|skipped) echo "verdict=failure" >>"$GITHUB_OUTPUT" @@ -290,7 +305,7 @@ jobs: fi if [ "$RUN_ATTEMPT" != "1" ]; then - echo "::error::Exact CodeQL job was rerun without an authenticated terminal verdict." + echo "::error::Exact CodeQL job was rerun without an authenticated terminal verdict; GHAS identity and preserved SARIF are required for authenticated terminal proof." exit 1 fi echo "verdict=pending" >>"$GITHUB_OUTPUT" @@ -363,6 +378,7 @@ jobs: live_pr="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")" live_head="$(printf '%s' "$live_pr" | jq -r '.head.sha // empty')" live_base="$(printf '%s' "$live_pr" | jq -r '.base.sha // empty')" + live_merge="$(printf '%s' "$live_pr" | jq -r '.merge_commit_sha // empty')" live_base_ref="$(printf '%s' "$live_pr" | jq -r '.base.ref // empty')" live_head_ref="$(printf '%s' "$live_pr" | jq -r '.head.ref // empty')" live_state="$(printf '%s' "$live_pr" | jq -r 'if (.state | type) == "string" then .state else empty end')" @@ -382,8 +398,10 @@ jobs: echo "::error::CodeQL dispatch requires a canonical current run id." exit 1 fi - if ! [[ "$live_base" =~ ^[0-9a-fA-F]{40}$ ]] || [ -z "$live_base_ref" ] || [ -z "$live_head_ref" ]; then - echo "::error::Could not validate live pull request base identity before CodeQL dispatch." + if ! [[ "$live_base" =~ ^[0-9a-fA-F]{40}$ ]] || + ! [[ "$live_merge" =~ ^[0-9a-fA-F]{40}$ ]] || + [ -z "$live_base_ref" ] || [ -z "$live_head_ref" ]; then + echo "::error::Could not validate live pull request base/source identity before CodeQL dispatch." exit 1 fi @@ -420,13 +438,17 @@ jobs: pending_matrix='[]' while IFS= read -r entry; do language="$(printf '%s' "$entry" | jq -r '.language // empty')" - verdict_state="$(printf '%s' "$statuses" | jq -r --arg ctx "codeql-dispatch/${language}" ' + expected_context="codeql-dispatch/${language}/${live_base}" + expected_description="cwl1;h=${PR_HEAD_SHA};w=codeql-scan-dispatch;r=${REQUIRED_RUN_ID};s=${live_merge}" + verdict_state="$(printf '%s' "$statuses" | jq -r --arg ctx "$expected_context" --arg description "$expected_description" ' [ .[] | select(.context == $ctx) + | select(.description == $description) | select( (.creator.login // "" | ascii_downcase) as $creator | $creator == "opencode-agent" or $creator == "opencode-agent[bot]" + or $creator == "cwl-noema-review" or $creator == "cwl-noema-review[bot]" ) ] | first // {} | .state // empty @@ -484,5 +506,6 @@ jobs: --argjson matrix "$pending_matrix" \ --arg required_run_id "$REQUIRED_RUN_ID" \ --argjson required_jobs "$required_jobs" \ - '{event_type:"codeql-scan",client_payload:{target_repository:$target_repository,pr_number:$pr_number,pr_base_ref:$pr_base_ref,pr_base_sha:$pr_base_sha,pr_head_ref:$pr_head_ref,pr_head_sha:$pr_head_sha,matrix:$matrix,required_run_id:$required_run_id,required_jobs:$required_jobs}}' | + --arg producer_source_sha "$live_merge" \ + '{event_type:"codeql-scan-v2",client_payload:{target_repository:$target_repository,pr_number:$pr_number,pr_base_ref:$pr_base_ref,pr_base_sha:$pr_base_sha,pr_head:{schema:"1",ref:$pr_head_ref,sha:$pr_head_sha},producer_source_sha:$producer_source_sha,matrix:$matrix,required_run_id:$required_run_id,required_jobs:$required_jobs}}' | GH_TOKEN="$app_token" gh api -X POST repos/ContextualWisdomLab/.github/dispatches --input - diff --git a/.github/workflows/codeql-scan-dispatch.yml b/.github/workflows/codeql-scan-dispatch.yml index 7f6e717db0..9c02817198 100644 --- a/.github/workflows/codeql-scan-dispatch.yml +++ b/.github/workflows/codeql-scan-dispatch.yml @@ -589,7 +589,7 @@ jobs: - name: Detect optional Noema analysis-read credential id: noema_analysis_config - if: steps.gate.outcome == 'success' + if: always() && steps.live_metadata.outcome == 'success' env: TARGET_REPOSITORY: ${{ needs.validate-dispatch.outputs.target_repository }} NOEMA_APP_CLIENT_ID: ${{ vars.NOEMA_GITHUB_APP_CLIENT_ID || '' }} @@ -701,10 +701,23 @@ jobs: if-no-files-found: error retention-days: 7 + - name: Mint target-scoped Noema CodeQL status token + id: noema_status_token + if: always() && steps.noema_analysis_config.outputs.available == 'true' + 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: ${{ steps.noema_analysis_config.outputs.repository }} + permission-statuses: write + - name: Publish CodeQL dispatch status id: publish_status if: always() && steps.live_metadata.outcome == 'success' env: + NOEMA_STATUS_TOKEN: ${{ steps.noema_status_token.outputs.token || '' }} TARGET_APP_STATUS_TOKEN: ${{ steps.target_app_token.outputs.token || '' }} GITHUB_STATUS_READ_TOKEN: ${{ github.token }} PR_REVIEW_MERGE_STATUS_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || '' }} @@ -783,6 +796,11 @@ jobs: actual_creator="$(jq -r '.creator.login // "" | ascii_downcase' "$status_response" 2>/dev/null || true)" creator_trusted=false case "$token_label" in + noema-status-token) + case "$actual_creator" in + cwl-noema-review|cwl-noema-review\[bot\]) creator_trusted=true ;; + esac + ;; target-app-token|pr-review-merge-token|opencode-approve-token) case "$actual_creator" in opencode-agent|opencode-agent\[bot\]) creator_trusted=true ;; @@ -815,6 +833,9 @@ jobs: return 1 } + if post_status "noema-status-token" "${NOEMA_STATUS_TOKEN:-}"; then + exit 0 + fi if post_status "target-app-token" "$TARGET_APP_STATUS_TOKEN"; then exit 0 fi @@ -920,8 +941,34 @@ jobs: echo "token=$app_token" } >>"$GITHUB_OUTPUT" + - name: Resolve Noema settlement token configuration + id: noema_settlement_config + env: + NOEMA_APP_CLIENT_ID: ${{ vars.NOEMA_GITHUB_APP_CLIENT_ID || '' }} + NOEMA_APP_PRIVATE_KEY: ${{ secrets.NOEMA_GITHUB_APP_PRIVATE_KEY || '' }} + TARGET_REPOSITORY: ${{ needs.validate-dispatch.outputs.target_repository }} + run: | + set -euo pipefail + if [ -n "$NOEMA_APP_CLIENT_ID" ] && [ -n "$NOEMA_APP_PRIVATE_KEY" ]; then + printf 'repository=%s\n' "${TARGET_REPOSITORY#*/}" >>"$GITHUB_OUTPUT" + echo "available=true" >>"$GITHUB_OUTPUT" + fi + + - name: Mint target-scoped Noema CodeQL settlement token + id: noema_settlement_token + if: steps.noema_settlement_config.outputs.available == 'true' + 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: ${{ steps.noema_settlement_config.outputs.repository }} + permission-actions: write + - name: Settle exact CodeQL required run env: + NOEMA_WAKE_TOKEN: ${{ steps.noema_settlement_token.outputs.token || '' }} TARGET_APP_WAKE_TOKEN: ${{ steps.target_app_token.outputs.token || '' }} PR_REVIEW_MERGE_WAKE_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || '' }} OPENCODE_APPROVE_WAKE_TOKEN: ${{ secrets.OPENCODE_APPROVE_TOKEN || '' }} @@ -960,7 +1007,8 @@ jobs: } github_api() { - run_api "target-app-token" "$TARGET_APP_WAKE_TOKEN" "$@" || + run_api "noema-settlement-token" "${NOEMA_WAKE_TOKEN:-}" "$@" || + run_api "target-app-token" "$TARGET_APP_WAKE_TOKEN" "$@" || run_api "pr-review-merge-token" "$PR_REVIEW_MERGE_WAKE_TOKEN" "$@" || run_api "opencode-approve-token" "$OPENCODE_APPROVE_WAKE_TOKEN" "$@" || run_api "github-token" "$GITHUB_WAKE_TOKEN" "$@" @@ -1074,6 +1122,26 @@ jobs: echo "::error::CodeQL settlement rejected incomplete handler gate or SARIF evidence for ${language}." exit 1 fi + clean_gate_count="$(printf '%s' "$handler_jobs" | jq --arg name "$expected_job_name" --argjson attempt "$GITHUB_RUN_ATTEMPT" ' + [.[] | select( + .name == $name + and .status == "completed" + and .run_attempt == $attempt + and ([.steps[]? | select(.name == "Enforce CodeQL Medium+ SARIF gate" and .conclusion == "success")] | length) == 1 + )] | length + ')" + ghas_identity_count="$(printf '%s' "$handler_jobs" | jq --arg name "$expected_job_name" --argjson attempt "$GITHUB_RUN_ATTEMPT" ' + [.[] | select( + .name == $name + and .status == "completed" + and .run_attempt == $attempt + and ([.steps[]? | select(.name == "Verify GHAS base/head CodeQL configuration identity" and .conclusion == "success")] | length) == 1 + )] | length + ')" + if [ "$clean_gate_count" -eq 1 ] && [ "$ghas_identity_count" -ne 1 ]; then + echo "::error::CodeQL settlement rejected missing GHAS configuration identity proof for ${language}." + exit 1 + fi done < <(printf '%s' "$REQUIRED_JOBS" | jq -c '.[]') case "$RERUN_MODE" in @@ -1099,7 +1167,8 @@ jobs: return 1 } - if post_wake "target-app-token" "$TARGET_APP_WAKE_TOKEN" || + if post_wake "noema-settlement-token" "${NOEMA_WAKE_TOKEN:-}" || + post_wake "target-app-token" "$TARGET_APP_WAKE_TOKEN" || post_wake "pr-review-merge-token" "$PR_REVIEW_MERGE_WAKE_TOKEN" || post_wake "opencode-approve-token" "$OPENCODE_APPROVE_WAKE_TOKEN" || post_wake "github-token" "$GITHUB_WAKE_TOKEN"; then diff --git a/CHANGELOG.d/20260927-codeql-terminal-proof.md b/CHANGELOG.d/20260927-codeql-terminal-proof.md new file mode 100644 index 0000000000..ea4154fcec --- /dev/null +++ b/CHANGELOG.d/20260927-codeql-terminal-proof.md @@ -0,0 +1,9 @@ +## Fixed + +- Require a successful GHAS base/head configuration-identity proof and preserved + SARIF before a clean central CodeQL gate may settle or satisfy an exact required + run. A failed post-gate identity check can no longer be promoted to GREEN by a + wake-only fallback. +- Bind CodeQL terminal receipts to the live base, required run, head, and merge + source through the v2 dispatch protocol, preventing a trusted but stale commit + status from satisfying a retargeted or later required run. diff --git a/docs/adr/adr-0032-owned-codeql-status-and-settlement-authority.md b/docs/adr/adr-0032-owned-codeql-status-and-settlement-authority.md new file mode 100644 index 0000000000..50c6392edc --- /dev/null +++ b/docs/adr/adr-0032-owned-codeql-status-and-settlement-authority.md @@ -0,0 +1,50 @@ +--- +title: "ADR-0032: Owned CodeQL status and settlement authority" +status: Proposed +date: "2026-09-27" +authors: "Codex" +tags: [architecture, ci, security] +supersedes: "" +superseded_by: "" +--- + +# ADR-0032: Owned CodeQL status and settlement authority + +## Status + +Proposed. Requires #2405 complete terminal-proof foundation, owned-app installation permission acceptance, and an unchanged-head live canary before protected deployment is accepted. + +## Context + +DiskSage #473 dispatch 36305375849 encountered cross-repository HTTP403 during status publication and required-run settlement. Public app and organization installation metadata confirm opencode-agent is owned by anomalyco and has Actions/read and statuses/read. A consumer cannot change the external owner's app permissions. The organization-owned cwl-noema-review (app4291520) is already installed on all repositories with security_events/read; its private-key organization secret is available to central workflows. The existing target-scoped analysis-read token remains the GHAS reader. + +## Decision + +Use the existing owned Noema app for separate target-repository tokens: statuses/write solely for authenticated CodeQL receipt publication, and Actions/write solely for exact required-run settlement. Keep security_events/read in its existing separate read token. The installation must authorize those two write permissions; credentials cannot mint permissions the installation lacks. Optional mint failures retain existing fallback credentials and never create validation success. + +The owned status writer must publish as cwl-noema-review or cwl-noema-review[bot]; another returned creator is rejected. No arbitrary actor is added. Complete base/head/run/source/workflow receipt and terminal SARIF/GHAS proof from #2405 remain prerequisites; do not deploy the new receiver trust before that foundation. Preserve exact-run identity, supersession, rerun budget, SARIF preservation and Medium+ gates. + +## Consequences + +- POS-001: Removes dependence on an external app owner's unavailable write grants. +- POS-002: Reuses an installed app and keeps analysis, publication and lifecycle tokens separate and target scoped. +- NEG-001: Expands the owned installation's capabilities and therefore the impact of its private-key compromise. Restrict key access and retain the trusted default-branch workflow boundary; never export keys into reviewed source or logs. +- NEG-002: Needs owner-authenticated app settings and installation acceptance plus live verification. Unit contracts do not prove deployment or permission availability. + +## Alternatives Considered + +- ALT-001: Change the external OpenCode app. Rejected because anomalyco owns that app and its current grants cannot satisfy writes. +- ALT-002: Transfer a user's CLI token into CI. Rejected: broad personal credentials are unnecessary and not copied. +- ALT-003: Bypass identity/receipt checks or synthesize success. Rejected because that removes the security proof. +- ALT-004: Reuse the analysis-read token for mutations. Rejected because its read-only contract must remain unchanged. + +## Implementation Notes + +- IMP-001: Pin the existing create-github-app-token action and request exactly one target repository and one write permission per writer token. +- IMP-002: Grant Actions/write and Commit statuses/write to the owned app and accept the installation update; do not add Code Scanning writes. +- IMP-003: Accept deployment only after real current-head scan, GHAS identity, receipt creator, one exact run-wide wake and terminal required verdict are verified. References: ContextualWisdomLab/.github#2276, #1929 and #2405. + +## References + +GitHub. (n.d.). *Create GitHub App token*. https://github.com/actions/create-github-app-token +GitHub. (n.d.). *Choosing permissions for a GitHub App*. https://docs.github.com/en/apps/creating-github-apps/setting-up-a-github-app/choosing-permissions-for-a-github-app diff --git a/docs/doctoring/codeql-terminal-proof-2352.md b/docs/doctoring/codeql-terminal-proof-2352.md new file mode 100644 index 0000000000..cfd2b356a3 --- /dev/null +++ b/docs/doctoring/codeql-terminal-proof-2352.md @@ -0,0 +1,66 @@ +# CodeQL terminal-proof settlement (#2352) + +## Incident + +On `.github#2352@f1a8dc813e6dba4e4905bf3e1b770b6d44344944`, required CodeQL +run `35805450471` initially failed pending and was later rerun. Attempt 2 jobs +`107353895415` (Actions) and `107353895562` (Python) became GREEN by reading +the successful `Enforce CodeQL Medium+ SARIF gate` step from producer run +`35841640640`. + +The producer jobs were nevertheless terminal failures: the later +`Verify GHAS base/head CodeQL configuration identity` step received HTTP 403. +The gate-only fallback therefore hid the exact credential/permission defect +tracked by `#2275` and `#2276`. + +## Root cause and boundary + +The required receiver and settlement contract treated one successful SARIF +gate step as terminal success even when a later mandatory proof failed. This +was originally allowed so a wake-only API failure could not invalidate an +otherwise complete scan, but the contract did not distinguish that harmless +late failure from GHAS identity or SARIF-preservation failure. + +A clean result recovered from a producer job whose overall conclusion is +failure now requires the same three proof units in both paths: + +1. `Enforce CodeQL Medium+ SARIF gate` succeeds; +2. `Verify GHAS base/head CodeQL configuration identity` succeeds; and +3. `Preserve CodeQL SARIF evidence` succeeds. + +A later failure confined to waking the exact required job remains outside the +scan verdict and may still be reconciled. A Medium+ gate failure remains a +terminal security failure and does not require a successful GHAS identity +step. A producer job whose overall conclusion is success remains authenticated +terminal proof because GitHub completed its non-optional steps successfully. +Missing, duplicate, skipped, cancelled, or failed proof on the failed-job clean +fallback stays fail-closed. + +An independent review found a second boundary defect before merge: the +required receiver and coordinator trusted the legacy +`codeql-dispatch/` commit status using only head SHA and publisher. +GitHub retains statuses on a commit, so the same head could reuse a success +from an earlier base, required run, or producer protocol after a PR retarget. +The current producer and consumers now use the v2 receipt exclusively: + +- context: `codeql-dispatch//`; +- description: exact head SHA, required run ID, workflow identity, and live + merge-source SHA; and +- publisher: the existing allowlisted app identity. + +The coordinator dispatches `codeql-scan-v2` with the versioned `pr_head` +envelope and live merge source. A legacy or otherwise stale status is ignored, +so the exact run performs or reuses only its own base/source-bound scan. + +## Verification and ownership + +Executable regressions reproduce the direct receiver and run-wide settlement +false-GREEN surfaces plus stale trusted-status reuse. They are RED on protected +`main` and GREEN with the proof contract. Focused workflow tests pass 91/91; +the complete repository suite passes 3,372 tests with 28 skips and 40 subtests. + +The central `.github` workflow remains the canonical owner. Do not copy the +workflow into a consumer, synthesize a status, accept clean SARIF alone, or +weaken the GHAS identity proof. `#2275`/`#2276` still own the real credential +and target permission repair; this change prevents that missing authority from +being mislabeled as a successful required check. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index c33b01f597..56943a1be5 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -3456,3 +3456,13 @@ alone -- it is a documented multi-PR hot-file collision zone. Contract: **Gap / RCA.** Exact-head SAST run [36243375994](https://github.com/ContextualWisdomLab/.github/actions/runs/36243375994), job `108407968534`, reported `python.lang.security.insecure-hash-algorithms.insecure-hash-algorithm-sha1` at `scripts/ci/pingora_edge_policy.py:602`. The call recomputes Git's protocol-defined `blob \\0` object ID with `usedforsecurity=False`; it is equality evidence for the exact GitHub blob, not a cryptographic signature. Replacing it with SHA-256 would contradict the upstream 40-hex blob identifier and remove tamper detection. **Action / evidence.** RED is the exact hosted failure above. Commit `53f447f73f0ef33eb708bf44202ec4d5954ade66`, formatted by `d00cdff974f5ac665a5f7481620d550735bd26c8`, adds one rule-scoped `nosemgrep` annotation plus the protocol rationale without changing the hash input, comparison, download bound, or failure behavior. Existing executable cases still require exact byte count and reject altered bytes by Git blob-ID mismatch. Completion requires fresh exact-head SAST GREEN, the remaining protected checks, no unresolved actionable review thread, qualifying independent approval, and ordinary merge. + +## 2026-09-27 CodeQL terminal-proof fallback run identity + +**Status:** Proposed on `ContextualWisdomLab/.github#2405`; direct repair parent `5a77a8c711bc93330c24a4821dff7439f600a264`, tree `5ce8ba7448cb878a5b130ed1acaba1578e4940fd`. This documentation-only successor preserves that executable tree; the PR body is the authority for the current exact head and hosted-run IDs. Merge and required-workflow admission remain HOLD. + +**Context Map / owner.** The central `.github` CodeQL required-workflow and dispatch bounded context owns dispatch identity, terminal evidence, and exact job recovery. Product repositories consume the protected workflow contract; they do not copy the producer or manufacture success receipts. + +**Gap / failure scene.** The v2 handler names a run with `head/base/required-run/producer-source`, but its required-workflow fallback looked up only `head/base/required-run`. When authenticated status publication is unavailable, a completed clean handler job could not be found and a rerun ended false RED. Omitting the producer source would also allow a regenerated live merge revision to reuse predecessor evidence. + +**Action / evidence.** Correct the fallback lookup to include the live merge source and retain fail-closed base, head, required-run, workflow-path, job-name, GHAS-identity, and SARIF checks. The test-first repair reproduced two failures, then passed 96 focused workflow-contract tests; the new edge case rejects a stale merge-source title. Ruff E9/F/I on the changed dispatch-contract file and `git diff --check` pass. Fresh hosted Checks and a qualifying independent approval are still required on the unchanged executable delta before merge. diff --git a/tests/test_code_scanning_required_workflow_contract.py b/tests/test_code_scanning_required_workflow_contract.py index 19933303d8..dbabff9705 100644 --- a/tests/test_code_scanning_required_workflow_contract.py +++ b/tests/test_code_scanning_required_workflow_contract.py @@ -45,4 +45,6 @@ def test_ruleset_requires_dispatch_safe_codeql_pr() -> None: assert workflow_path in audit.REQUIRED_WORKFLOW_PATHS assert "uses: github/codeql-action" not in workflow - assert "event_type:\"codeql-scan\"" in workflow + assert "event_type:\"codeql-scan-v2\"" in workflow + assert 'pr_head:{schema:"1",ref:$pr_head_ref,sha:$pr_head_sha}' in workflow + assert "producer_source_sha:$producer_source_sha" in workflow diff --git a/tests/test_codeql_pr_workflow_contract.py b/tests/test_codeql_pr_workflow_contract.py index c314a6ea2b..977a47deed 100644 --- a/tests/test_codeql_pr_workflow_contract.py +++ b/tests/test_codeql_pr_workflow_contract.py @@ -51,11 +51,12 @@ def test_codeql_pr_workflow_structure() -> None: assert "analyze-merge:" not in workflow assert "CodeQL merge preview" not in workflow assert "refs/pull/{0}/merge" not in workflow - assert "event_type:\"codeql-scan\"" in workflow + assert "event_type:\"codeql-scan-v2\"" in workflow assert "repos/ContextualWisdomLab/.github/dispatches" in workflow - # Reads the authenticated context codeql-scan-dispatch.yml publishes; it - # never publishes that status from the required workflow. - assert '--arg ctx "codeql-dispatch/${LANGUAGE}"' in workflow + # Reads the authenticated, base-bound context that + # codeql-scan-dispatch.yml publishes; the required workflow never writes it. + assert 'expected_context="codeql-dispatch/${LANGUAGE}/${live_base}"' in workflow + assert ".description == $description" in workflow assert "commits/${PR_HEAD_SHA}/statuses" in workflow @@ -76,7 +77,7 @@ def test_codeql_pr_shards_do_not_dispatch_and_coordinator_sends_the_full_matrix_ assert "id: dispatch" in analyze_head assert "repos/ContextualWisdomLab/.github/dispatches" not in analyze_head - assert 'event_type:"codeql-scan"' not in analyze_head + assert 'event_type:"codeql-scan-v2"' not in analyze_head assert 'matrix:[{language:$language,"build-mode":$build_mode}]' not in workflow assert "required_job_id:$required_job_id" not in analyze_head assert "required_language:$required_language" not in analyze_head @@ -109,7 +110,9 @@ def test_codeql_coordinator_dispatches_later_attempts_when_no_terminal_verdict() assert "github.run_attempt == 1" not in coordinator_if assert "All detected CodeQL languages already have authenticated terminal verdicts" in coordinator - assert 'event_type:"codeql-scan"' in coordinator + assert 'event_type:"codeql-scan-v2"' in coordinator + assert 'pr_head:{schema:"1",ref:$pr_head_ref,sha:$pr_head_sha}' in coordinator + assert "producer_source_sha:$producer_source_sha" in coordinator assert "required_jobs:$required_jobs" in coordinator assert "required_run_id:$required_run_id" in coordinator assert "required_job_id:$required_job_id" not in coordinator @@ -152,19 +155,42 @@ def test_codeql_pr_dispatch_and_release_run_blocks_are_valid_bash() -> None: COORDINATOR_STEP_NAME = "Dispatch current-head CodeQL scan" _TEST_HEAD_SHA = "b" * 40 _TEST_BASE_SHA = "a" * 40 +_TEST_PRODUCER_SOURCE_SHA = "c" * 40 _TEST_REQUIRED_RUN_ID = "42" +def _bound_status( + language: str, + state: str, + *, + head_sha: str = _TEST_HEAD_SHA, + base_sha: str = _TEST_BASE_SHA, + required_run_id: str = _TEST_REQUIRED_RUN_ID, + producer_source_sha: str = _TEST_PRODUCER_SOURCE_SHA, +) -> dict: + """Return a v2 receipt bound to the exact base, run, and merge source.""" + return { + "context": f"codeql-dispatch/{language}/{base_sha}", + "state": state, + "description": ( + f"cwl1;h={head_sha};w=codeql-scan-dispatch;" + f"r={required_run_id};s={producer_source_sha}" + ), + "creator": {"login": "opencode-agent[bot]"}, + } + + def _dispatch_scan_title( *, head_sha: str = _TEST_HEAD_SHA, base_sha: str = _TEST_BASE_SHA, required_run_id: str = _TEST_REQUIRED_RUN_ID, + producer_source_sha: str = _TEST_PRODUCER_SOURCE_SHA, ) -> str: """Return the immutable CodeQL dispatch run-name for one required shard.""" return ( "CodeQL Scan Dispatch ContextualWisdomLab/naruon#42@" - f"{head_sha}/{base_sha}/{required_run_id}" + f"{head_sha}/{base_sha}/{required_run_id}/{producer_source_sha}" ) @@ -209,6 +235,7 @@ def _run_verdict_read( live_pr = { "head": {"sha": live_head}, "base": {"sha": _TEST_BASE_SHA}, + "merge_commit_sha": _TEST_PRODUCER_SOURCE_SHA, "state": live_state, } @@ -293,9 +320,8 @@ def _run_verdict_read( def test_codeql_pr_one_shot_read_ignores_status_forged_by_non_opencode_creator(tmp_path: Path) -> None: """A PR-forged 'codeql-dispatch/: success' status must not stand in for the real verdict. - Only a status published by codeql-scan-dispatch.yml's own app identity - (opencode-agent[bot], minted via the same OIDC exchange - opencode-review-dispatch.yml uses) may satisfy the verdict read -- matching the + Only a status published by the handler's explicitly trusted app identities + (OpenCode or the organization-owned Noema status writer) may satisfy the verdict read -- matching the context string alone is not enough, since anyone with statuses:write on the repository can publish an arbitrary context (ADR 0025, "Poll target cannot be spoofed by the PR author"). This proves the forged success is @@ -306,11 +332,7 @@ def test_codeql_pr_one_shot_read_ignores_status_forged_by_non_opencode_creator(t tmp_path, statuses=[ {"context": "codeql-dispatch/python", "state": "success", "creator": {"login": "attacker"}}, - { - "context": "codeql-dispatch/python", - "state": "failure", - "creator": {"login": "opencode-agent[bot]"}, - }, + _bound_status("python", "failure"), ], ) assert dispatch_result.returncode == 0, dispatch_result.stderr @@ -320,6 +342,17 @@ def test_codeql_pr_one_shot_read_ignores_status_forged_by_non_opencode_creator(t def test_codeql_pr_one_shot_read_accepts_the_opencode_agent_creator(tmp_path: Path) -> None: """The legitimate handler's own success status is accepted once creator identity matches.""" + dispatch_result, verdict_result = _run_verdict_read( + tmp_path, + statuses=[_bound_status("python", "success")], + ) + assert dispatch_result.returncode == 0, dispatch_result.stderr + assert verdict_result.returncode == 0, verdict_result.stderr + assert "Current-head CodeQL dispatch verdict for python: success." in verdict_result.stdout + + +def test_codeql_pr_one_shot_read_rejects_stale_unbound_status(tmp_path: Path) -> None: + """A trusted creator cannot make a status from an earlier base/run current.""" dispatch_result, verdict_result = _run_verdict_read( tmp_path, statuses=[ @@ -330,9 +363,9 @@ def test_codeql_pr_one_shot_read_accepts_the_opencode_agent_creator(tmp_path: Pa } ], ) - assert dispatch_result.returncode == 0, dispatch_result.stderr - assert verdict_result.returncode == 0, verdict_result.stderr - assert "Current-head CodeQL dispatch verdict for python: success." in verdict_result.stdout + assert dispatch_result.returncode == 1, dispatch_result.stdout + assert verdict_result.returncode == 1 + assert "without an authenticated terminal verdict" in dispatch_result.stdout def test_codeql_pr_one_shot_read_accepts_clean_gate_when_wake_step_failed_job( @@ -355,6 +388,14 @@ def test_codeql_pr_one_shot_read_accepts_clean_gate_when_wake_step_failed_job( "name": "Enforce CodeQL Medium+ SARIF gate", "conclusion": "success", }, + { + "name": "Verify GHAS base/head CodeQL configuration identity", + "conclusion": "success", + }, + { + "name": "Preserve CodeQL SARIF evidence", + "conclusion": "success", + }, { "name": "Wake exact CodeQL required job", "conclusion": "failure", @@ -366,10 +407,49 @@ def test_codeql_pr_one_shot_read_accepts_clean_gate_when_wake_step_failed_job( ) assert dispatch_result.returncode == 0, dispatch_result.stderr + dispatch_result.stdout assert verdict_result.returncode == 0, verdict_result.stderr + verdict_result.stdout - assert "completed CodeQL dispatch scan gate for python: success" in dispatch_result.stdout + assert "completed CodeQL dispatch proof for python" in dispatch_result.stdout assert "Current-head CodeQL dispatch verdict for python: success." in verdict_result.stdout +def test_codeql_pr_one_shot_read_rejects_clean_gate_when_ghas_identity_failed( + tmp_path: Path, +) -> None: + """A clean SARIF gate cannot hide a later GHAS identity proof failure.""" + head_sha = _TEST_HEAD_SHA + title = _dispatch_scan_title(head_sha=head_sha) + dispatch_result, verdict_result = _run_verdict_read( + tmp_path, + statuses=[], + dispatch_runs={"workflow_runs": [_completed_dispatch_run(title=title)]}, + dispatch_jobs={ + "jobs": [ + { + "name": "CodeQL dispatch scan (python)", + "conclusion": "failure", + "steps": [ + { + "name": "Enforce CodeQL Medium+ SARIF gate", + "conclusion": "success", + }, + { + "name": "Verify GHAS base/head CodeQL configuration identity", + "conclusion": "failure", + }, + { + "name": "Preserve CodeQL SARIF evidence", + "conclusion": "success", + }, + ], + } + ] + }, + ) + + assert dispatch_result.returncode == 1 + assert "authenticated terminal proof" in dispatch_result.stdout + assert verdict_result.returncode == 1 + + def test_codeql_pr_one_shot_read_accepts_completed_dispatch_scan_job_when_status_unpublishable( tmp_path: Path, ) -> None: @@ -493,8 +573,32 @@ def test_codeql_pr_rejects_completed_dispatch_scan_from_a_different_required_run assert "completed CodeQL dispatch scan job for python: success" not in dispatch_result.stdout +def test_codeql_pr_rejects_completed_dispatch_scan_from_a_stale_merge_source( + tmp_path: Path, +) -> None: + """A regenerated live merge source cannot reuse its predecessor's scan.""" + stale_title = _dispatch_scan_title(producer_source_sha="d" * 40) + dispatch_result, _verdict_result = _run_verdict_read( + tmp_path, + statuses=[], + dispatch_runs={"workflow_runs": [_completed_dispatch_run(title=stale_title)]}, + dispatch_jobs={ + "jobs": [ + { + "name": "CodeQL dispatch scan (python)", + "conclusion": "success", + } + ] + }, + ) + + assert dispatch_result.returncode == 1, dispatch_result.stderr + dispatch_result.stdout + assert "without an authenticated terminal verdict" in dispatch_result.stdout + assert "completed CodeQL dispatch scan job for python: success" not in dispatch_result.stdout + + def test_codeql_pr_fallback_binds_live_base_and_required_run_identity() -> None: - """The required shard looks up the public dispatch run by immutable identity.""" + """The shard binds fallback proof to base, run, and live merge source.""" workflow = WORKFLOW_PATH.read_text(encoding="utf-8") shard = workflow.split(" analyze-head:\n", 1)[1].split( " dispatch-current-head:\n", 1 @@ -504,9 +608,9 @@ def test_codeql_pr_fallback_binds_live_base_and_required_run_identity() -> None: assert 'live_base="$(printf' in shard assert ( 'expected_title="CodeQL Scan Dispatch ${TARGET_REPOSITORY}#${PR_NUMBER}' - '@${PR_HEAD_SHA}/${live_base}/${REQUIRED_RUN_ID}"' + '@${PR_HEAD_SHA}/${live_base}/${REQUIRED_RUN_ID}/${live_merge}"' ) in shard - assert "Could not validate live pull request base SHA before CodeQL verdict read." in shard + assert "Could not validate live pull request base/source SHA before CodeQL verdict read." in shard def test_codeql_action_steps_use_one_version_per_workflow() -> None: @@ -639,6 +743,7 @@ def test_codeql_pr_attempt_one_without_verdict_fails_pending_without_dispatch( { "head": {"sha": head_sha}, "base": {"sha": _TEST_BASE_SHA}, + "merge_commit_sha": _TEST_PRODUCER_SOURCE_SHA, "state": "open", } ), @@ -766,6 +871,7 @@ def _run_coordinator( "state": "open", "head": {"sha": head_sha, "ref": "feature"}, "base": {"sha": "a" * 40, "ref": "main"}, + "merge_commit_sha": "c" * 40, } jobs = jobs or { "total_count": 2, @@ -841,10 +947,12 @@ def test_codeql_coordinator_posts_one_dispatch_for_every_pending_language( "repos/ContextualWisdomLab/.github/dispatches" ] payload = json.loads(post_body.read_text(encoding="utf-8")) - assert payload["event_type"] == "codeql-scan" + assert payload["event_type"] == "codeql-scan-v2" client = payload["client_payload"] assert client["target_repository"] == "ContextualWisdomLab/naruon" assert client["pr_number"] == "42" + assert client["pr_head"] == {"schema": "1", "ref": "feature", "sha": "b" * 40} + assert client["producer_source_sha"] == "c" * 40 assert client["required_run_id"] == "99" assert "required_job_id" not in client assert "required_language" not in client @@ -863,16 +971,8 @@ def test_codeql_coordinator_skips_dispatch_when_every_language_has_a_verdict( result, post_log, post_body = _run_coordinator( tmp_path, statuses=[ - { - "context": "codeql-dispatch/python", - "state": "success", - "creator": {"login": "opencode-agent[bot]"}, - }, - { - "context": "codeql-dispatch/actions", - "state": "failure", - "creator": {"login": "opencode-agent[bot]"}, - }, + _bound_status("python", "success", required_run_id="99"), + _bound_status("actions", "failure", required_run_id="99"), ], ) @@ -917,6 +1017,7 @@ def test_codeql_coordinator_dispatches_the_live_base_after_a_same_head_retarget( "state": "open", "head": {"sha": "b" * 40, "ref": "feature"}, "base": {"sha": live_base, "ref": "release"}, + "merge_commit_sha": "d" * 40, }, env_overrides={"PR_BASE_SHA": "a" * 40, "PR_BASE_REF": "main"}, ) @@ -928,7 +1029,8 @@ def test_codeql_coordinator_dispatches_the_live_base_after_a_same_head_retarget( client = json.loads(post_body.read_text(encoding="utf-8"))["client_payload"] assert client["pr_base_sha"] == live_base assert client["pr_base_ref"] == "release" - assert client["pr_head_sha"] == "b" * 40 + assert client["pr_head"] == {"schema": "1", "ref": "feature", "sha": "b" * 40} + assert client["producer_source_sha"] == "d" * 40 assert client["required_run_id"] == "99" @@ -1034,3 +1136,16 @@ def test_codeql_pr_scopes_dispatch_history_to_required_run_creation() -> None: assert '-f event=repository_dispatch' in script assert '--paginate --slurp' in script assert 'select(.display_title == $title or .name == $title)' in script + + +def test_codeql_pr_accepts_only_bound_organization_owned_noema_status(tmp_path: Path) -> None: + """The owned publisher cannot reuse an earlier producer's success receipt.""" + for stale in (False, True): + case = tmp_path / ("stale" if stale else "current") + case.mkdir() + status = _bound_status("python", "success", + producer_source_sha="d" * 40 if stale else _TEST_PRODUCER_SOURCE_SHA) + status["creator"] = {"login": "cwl-noema-review[bot]"} + dispatch, verdict = _run_verdict_read(case, statuses=[status]) + assert (dispatch.returncode == 0) != stale, dispatch.stdout + dispatch.stderr + assert (verdict.returncode == 0) != stale, verdict.stdout + verdict.stderr diff --git a/tests/test_codeql_scan_dispatch_workflow_contract.py b/tests/test_codeql_scan_dispatch_workflow_contract.py index b74600fc8c..290211e85d 100644 --- a/tests/test_codeql_scan_dispatch_workflow_contract.py +++ b/tests/test_codeql_scan_dispatch_workflow_contract.py @@ -1143,6 +1143,10 @@ def _run_settlement_step( "run_attempt": 1, "steps": [ {"name": "Enforce CodeQL Medium+ SARIF gate", "conclusion": "success"}, + { + "name": "Verify GHAS base/head CodeQL configuration identity", + "conclusion": "success", + }, {"name": "Preserve CodeQL SARIF evidence", "conclusion": "success"}, ], }, @@ -1153,6 +1157,10 @@ def _run_settlement_step( "run_attempt": 1, "steps": [ {"name": "Enforce CodeQL Medium+ SARIF gate", "conclusion": "success"}, + { + "name": "Verify GHAS base/head CodeQL configuration identity", + "conclusion": "success", + }, {"name": "Preserve CodeQL SARIF evidence", "conclusion": "success"}, ], }, @@ -1571,6 +1579,49 @@ def test_dispatch_settlement_rejects_missing_handler_gate_steps(tmp_path: Path) assert not post_log.exists() +def test_dispatch_settlement_rejects_failed_ghas_identity_after_clean_gate( + tmp_path: Path, +) -> None: + """Settlement cannot wake a required run after GHAS identity proof failed.""" + result, post_log = _run_settlement_step( + tmp_path, + handler_jobs=[ + { + "name": "CodeQL dispatch scan (python)", + "status": "completed", + "conclusion": "failure", + "run_attempt": 1, + "steps": [ + {"name": "Enforce CodeQL Medium+ SARIF gate", "conclusion": "success"}, + { + "name": "Verify GHAS base/head CodeQL configuration identity", + "conclusion": "failure", + }, + {"name": "Preserve CodeQL SARIF evidence", "conclusion": "success"}, + ], + }, + { + "name": "CodeQL dispatch scan (actions)", + "status": "completed", + "conclusion": "success", + "run_attempt": 1, + "steps": [ + {"name": "Enforce CodeQL Medium+ SARIF gate", "conclusion": "success"}, + { + "name": "Verify GHAS base/head CodeQL configuration identity", + "conclusion": "success", + }, + {"name": "Preserve CodeQL SARIF evidence", "conclusion": "success"}, + ], + }, + ], + ) + + assert result.returncode == 1 + assert "missing GHAS configuration identity proof for python" in result.stdout + assert not post_log.exists() + + def test_dispatch_settlement_rejects_unproven_matrix_subset(tmp_path: Path) -> None: """Every required shard needs current handler gate and artifact evidence.""" result, post_log = _run_settlement_step( @@ -1694,3 +1745,76 @@ def test_codeql_scan_checkout_cleans_reused_workspace_without_persisting_token() assert 'persist-credentials: false' in block assert 'clean: true' in block assert 'git remote add origin' not in block + + +@pytest.mark.parametrize("creator,accepted", [ + ("cwl-noema-review[bot]", True), ("attacker", False), + ("opencode-agent[bot]", False), +]) +def test_owned_codeql_status_token_checks_its_actual_creator( + tmp_path: Path, creator: str, accepted: bool, +) -> None: + """The owned credential cannot silently publish as a different principal.""" + publish = _extract_run_block(WORKFLOW_PATH.read_text(), "Publish CodeQL dispatch status") + function = publish[publish.index("post_status() {"):publish.index('if post_status')] + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + gh = fake_bin / "gh" + response = json.dumps({"creator": {"login": creator}}) + gh.write_text("#!/bin/bash\nprintf '%s\\n' '" + response + "'\n") + gh.chmod(0o755) + env = {**os.environ, "PATH": f"{fake_bin}:{os.environ['PATH']}", + "FAKE_CREATOR": creator, "TARGET_REPOSITORY": "ContextualWisdomLab/naruon", + "HEAD_SHA": "b" * 40, "state": "success", + "receipt_context": "codeql-dispatch/python", "receipt_description": "verified", + "GITHUB_SERVER_URL": "https://github.com", "GITHUB_REPOSITORY": "ContextualWisdomLab/.github", + "GITHUB_RUN_ID": "100"} + result = subprocess.run(["bash"], input='set -euo pipefail\n' + function + + '\npost_status noema-status-token synthetic-owned-token\n', + env=env, text=True, capture_output=True) + assert (result.returncode == 0) == accepted, result.stdout + result.stderr + + +def test_owned_codeql_wake_preserves_exact_run_wide_settlement(tmp_path: Path) -> None: + """The owned token follows the existing full proof before a single mutation.""" + result, posts = _run_settlement_step(tmp_path, extra_env={ + "NOEMA_WAKE_TOKEN": "owned-token", "TARGET_APP_WAKE_TOKEN": "foreign-token", + "FAKE_DENIED_TOKEN": "foreign-token", "GITHUB_WAKE_TOKEN": "", + }) + assert result.returncode == 0, result.stdout + result.stderr + assert "using noema-settlement-token" in result.stdout + assert posts.read_text().splitlines() == [ + "repos/ContextualWisdomLab/naruon/actions/runs/42/rerun-failed-jobs", + ] + + +def test_owned_codeql_writers_are_separate_target_scoped_credentials() -> None: + """Read, status and wake tokens each retain one narrow permission purpose.""" + workflow = WORKFLOW_PATH.read_text() + for name,config,permission in ( + ("Noema CodeQL status token", "noema_analysis_config", "statuses"), + ("Noema CodeQL settlement token", "noema_settlement_config", "actions"), + ): + step = workflow.split(f" - name: Mint target-scoped {name}\n", 1)[1].split(" - name:", 1)[0] + assert f"repositories: ${{{{ steps.{config}.outputs.repository }}}}" in step + assert f"permission-{permission}: write" in step + assert "permission-security-events" not in step + assert "continue-on-error: true" in step + + +def test_owned_status_configuration_survives_failed_analysis_gate(tmp_path: Path) -> None: + """A failed gate can publish failure without minting an analysis reader.""" + workflow = WORKFLOW_PATH.read_text() + config = workflow.split(" id: noema_analysis_config\n", 1)[1].split(" - name:", 1)[0] + assert "if: always() && steps.live_metadata.outcome == 'success'" in config + reader = workflow.split(" id: noema_analysis_token\n", 1)[1].split(" - name:", 1)[0] + assert "if: steps.gate.outcome == 'success'" in reader + output = tmp_path / "outputs" + script = _extract_run_block(workflow, "Detect optional Noema analysis-read credential") + result = subprocess.run(["bash"], input=script, text=True, capture_output=True, + env={**os.environ, "TARGET_REPOSITORY": "ContextualWisdomLab/disksage", + "NOEMA_APP_CLIENT_ID": "synthetic-client", + "NOEMA_APP_PRIVATE_KEY": "synthetic-key", + "GITHUB_OUTPUT": str(output)}) + assert result.returncode == 0, result.stdout + result.stderr + assert output.read_text().splitlines() == ["repository=disksage", "available=true"]