diff --git a/.github/workflows/codeql-pr.yml b/.github/workflows/codeql-pr.yml index c21c8446df..710a82c45f 100644 --- a/.github/workflows/codeql-pr.yml +++ b/.github/workflows/codeql-pr.yml @@ -152,6 +152,8 @@ jobs: permissions: contents: read id-token: write + pull-requests: read + statuses: read strategy: fail-fast: false matrix: ${{ fromJSON(needs.detect-languages.outputs.matrix) }} @@ -298,6 +300,8 @@ jobs: contents: read id-token: write actions: read + pull-requests: read + statuses: read steps: - name: Dispatch current-head CodeQL scan env: diff --git a/CHANGELOG.md b/CHANGELOG.md index 707c18532e..f73166f44e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +### CodeQL required workflow denies private consumers a read they need for their own PR + +- `.github/workflows/codeql-pr.yml`'s `analyze-head` and `dispatch-current-head` jobs called `gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}"` and later `repos/${TARGET_REPOSITORY}/commits/${PR_HEAD_SHA}/statuses` while holding only `contents: read` (plus `id-token: write`, and `actions: read` on the coordinator job) -- reads GitHub's REST contract gates behind the `pull-requests: read` and `statuses: read` fine-grained permissions on a private repository. Public consumers never surfaced this because GET on a public repository needs no such grant, but private consumer ContextualWisdomLab/late-life-anxiety-reanalysis's PR #10 (head `a1cd5bc6783c6510dfcf937f523c733366e82213`, run `34700410434`) failed both required-workflow jobs (`103571590442`, `103571810868`) at their first API call with `gh: Resource not accessible by integration (HTTP 403)`. Both jobs now also hold `pull-requests: read` and `statuses: read`; no write permission is added anywhere, and `actions: write` stays absent, so `tests/test_codeql_pr_workflow_contract.py::test_codeql_required_workflow_does_not_gain_actions_write` needed no change. New regression test `test_codeql_pr_jobs_hold_read_grants_private_consumers_need` pins the exact grant set. See `docs/doctoring/codeql-pr-private-consumer-read-permissions.md`. Refs ContextualWisdomLab/late-life-anxiety-reanalysis#10. + ### Failed-check finding names the Strix sandbox instead of the gateway - `opencode-review-dispatch.yml`'s `emit_strix_provider_failure_finding` rendered one fixed finding for every `STRIX_PROVIDER_UNAVAILABLE` line, whose Root cause read "The contextual-orchestrator gateway or its discovered provider pool was unavailable for this run". `#1953` had just given the Strix sandbox bootstrap failure its own second verdict token (`STRIX_SANDBOX_UNAVAILABLE`) precisely because that attribution is wrong for it -- the sandbox container never reaches its Caido proxy, so the run dies before the gateway serves anything -- and this consumer re-applied the wrong attribution one step downstream, into the review findings and the failure census. The emitter now branches on the second token: a sandbox verdict gets a finding that names Strix's sandbox, says the verdict does not name the gateway, and tells the reader not to change gateway or provider configuration on its strength. A `STRIX_PROVIDER_UNAVAILABLE` line without the token keeps its existing text verbatim, so the gateway class has no regression surface. No test covered this finding text at all before (`gateway or its discovered provider pool` matched nothing under `tests/`); `tests/test_opencode_dispatch_strix_sandbox_finding.py` now runs the production emitter from the published run block and pins both directions plus the no-signal case. Refs #1953, #1935. diff --git a/docs/doctoring/codeql-pr-private-consumer-read-permissions.md b/docs/doctoring/codeql-pr-private-consumer-read-permissions.md new file mode 100644 index 0000000000..fc0cf62d7e --- /dev/null +++ b/docs/doctoring/codeql-pr-private-consumer-read-permissions.md @@ -0,0 +1,26 @@ +# CodeQL required workflow denies private consumers a read they need — 2026-09-13 + +## Symptom + +Private consumer `ContextualWisdomLab/late-life-anxiety-reanalysis` PR #10 (head `a1cd5bc6783c6510dfcf937f523c733366e82213`, run `34700410434`) failed both org-required `.github/workflows/codeql-pr.yml` jobs at their first API call, each with `gh: Resource not accessible by integration (HTTP 403)`. `CodeQL compatibility analysis (python)` (job `103571590442`), step "Read current-head CodeQL dispatch verdict", calls `gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}"` under `GH_TOKEN: ${{ github.token }}`; the runner printed effective token permissions of Contents: read, Metadata: read only (declared: `contents: read`, `id-token: write`). `Dispatch current-head CodeQL scan` (job `103571810868`) makes the same GET, then later reads `repos/${TARGET_REPOSITORY}/commits/${PR_HEAD_SHA}/statuses`, under `contents: read`, `id-token: write`, `actions: read`. Public consumers (fast-mlsirm, pg-erd-cloud, naruon, html4tree) pass the identical workflow only because GET on a *public* repository needs no fine-grained grant; the defect is specific to private repositories. + +## Root cause + +Neither job declared the fine-grained read permissions GitHub's REST contract requires for these calls on a private repository: "Get a pull request" needs `pull-requests: read`; "List commit statuses for a reference" needs `statuses: read`. Missing both, the minted `GITHUB_TOKEN` had no read access to pull-request or status data on a private repo, and testing against public consumers never exercised the gap because anonymous-equivalent GETs on public repository resources are always permitted. + +## Repair + +Added `pull-requests: read` and `statuses: read` to the `analyze-head` and `dispatch-current-head` job `permissions:` blocks in `.github/workflows/codeql-pr.yml`, preserving declaration order (contents, id-token, [actions], pull-requests, statuses). No write permission is added anywhere; `actions: write` remains absent, still guarded by the existing `test_codeql_required_workflow_does_not_gain_actions_write` regression test. + +## Local evidence + +New test `test_codeql_pr_jobs_hold_read_grants_private_consumers_need` in `tests/test_codeql_pr_workflow_contract.py` slices both permission blocks the same way the neighboring `actions: write` guard does and asserts each holds exactly `pull-requests: read` and `statuses: read` with no `actions: write`. RED: 1 failed (`assert [] == ['read']`). GREEN: 1 passed. Combined focused run across the five CodeQL/required-workflow contract test files: 149 passed. Full repository suite and `actionlint` result are recorded in the pull request description. + +## Hosted acceptance still required + +This repair is unverified against GitHub's live permission enforcement. A newly loaded central SHA carrying this change must still pass both `analyze-head` and `dispatch-current-head` on the private consumer's exact current head before the defect is resolved end-to-end. Separately, the later `repository_dispatch` POST from `dispatch-current-head` to `ContextualWisdomLab/.github` using the OpenCode app token has not yet been exercised from a private consumer at all, and may surface a distinct scoping issue of its own once this read-permission blocker is cleared. + +## References + +- GitHub REST, "Get a pull request": https://docs.github.com/en/rest/pulls/pulls#get-a-pull-request (fine-grained permission: `pull-requests: read`) +- GitHub REST, "List commit statuses for a reference": https://docs.github.com/en/rest/commits/statuses#list-commit-statuses-for-a-reference (fine-grained permission: `statuses: read`) diff --git a/tests/test_codeql_pr_workflow_contract.py b/tests/test_codeql_pr_workflow_contract.py index dc67eef258..d5a54ccba5 100644 --- a/tests/test_codeql_pr_workflow_contract.py +++ b/tests/test_codeql_pr_workflow_contract.py @@ -515,6 +515,45 @@ def test_codeql_required_workflow_does_not_gain_actions_write() -> None: assert "actions: write" not in coordinator_permissions +def test_codeql_pr_jobs_hold_read_grants_private_consumers_need() -> None: + """analyze-head and dispatch-current-head need pull-requests/statuses reads. + + Consumer evidence: ContextualWisdomLab/late-life-anxiety-reanalysis PR #10 + (head a1cd5bc6783c6510dfcf937f523c733366e82213, run 34700410434). Both + required-workflow jobs failed at their first API call with + `gh: Resource not accessible by integration (HTTP 403)`: + - job "CodeQL compatibility analysis (python)" (job 103571590442), step + "Read current-head CodeQL dispatch verdict", calling + `gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}"` with only + `contents: read` + `id-token: write` (effective token printed by the + runner: Contents: read, Metadata: read). + - job "Dispatch current-head CodeQL scan" (job 103571810868), step + "Dispatch current-head CodeQL scan", the same GET plus a later read of + `repos/${TARGET_REPOSITORY}/commits/${PR_HEAD_SHA}/statuses`, with + `contents: read`, `id-token: write`, `actions: read`. + + Public consumers (fast-mlsirm, pg-erd-cloud, naruon, html4tree) passed + only because GET on a public repository does not need the grant. + GitHub's REST contract requires the `pull-requests: read` fine-grained + permission for "Get a pull request" and `statuses: read` for "List commit + statuses for a reference" on private repositories. + """ + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + shard_permissions = workflow.split(" analyze-head:\n", 1)[1].split( + " strategy:\n", 1 + )[0] + coordinator_permissions = workflow.split(" dispatch-current-head:\n", 1)[1].split( + " steps:\n", 1 + )[0] + + for block in (shard_permissions, coordinator_permissions): + assert re.findall(r"^ pull-requests: (\w+)$", block, re.MULTILINE) == [ + "read" + ] + assert re.findall(r"^ statuses: (\w+)$", block, re.MULTILINE) == ["read"] + assert "actions: write" not in block + + def test_codeql_pr_attempt_one_without_verdict_fails_pending_without_dispatch( tmp_path: Path, ) -> None: