diff --git a/.github/workflows/pr-review-merge-scheduler.yml b/.github/workflows/pr-review-merge-scheduler.yml index d32918cf45..33bb0c025c 100644 --- a/.github/workflows/pr-review-merge-scheduler.yml +++ b/.github/workflows/pr-review-merge-scheduler.yml @@ -496,6 +496,7 @@ jobs: SCHEDULER_DISPATCH_TOKEN: ${{ github.token }} SCHEDULER_READ_TOKEN: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.target_repository != '' && github.event.client_payload.target_repository != github.repository && (secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.token) || github.token }} SCHEDULER_MUTATION_TOKEN_SOURCE: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' && 'PR_REVIEW_MERGE_TOKEN' || secrets.OPENCODE_APPROVE_TOKEN != '' && 'OPENCODE_APPROVE_TOKEN' || steps.scheduler_app_token.outputs.available == 'true' && 'opencode-app' || 'github-token' }} + SCHEDULER_WORKFLOW_TOKEN: ${{ github.token }} SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY: ContextualWisdomLab/.github SCHEDULER_ALLOW_CROSS_REPO_REPOSITORY_DISPATCH: ${{ (secrets.PR_REVIEW_MERGE_TOKEN != '' || secrets.OPENCODE_APPROVE_TOKEN != '') && 'true' || 'false' }} run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 74cf0f7dc6..d8e88821b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -162,6 +162,15 @@ ## Proposed +- Prove that the scheduler's selected head-mutation credential is present and + distinct from the workflow `github.token`, even when its declared source is + allowlisted. Missing comparison evidence and same-token fallback now fail + closed, and later operator guidance renders from the immutable recorded + decision rather than re-reading mutable environment state. The valid-source + fixture now also exercises the defensive messaging guard; this closes the + sole missed statement reported by exact run `34179686961` without weakening + the production credential proof. + - Route scheduler Actions inventory and force-cancellation through the credential scoped to the repository hosting each run. Central required-workflow runs use the receiving repository runner token; target runs retain the explicit diff --git a/docs/doctoring/workflow-starting-mutation-credential-proof.md b/docs/doctoring/workflow-starting-mutation-credential-proof.md new file mode 100644 index 0000000000..251b664f1a --- /dev/null +++ b/docs/doctoring/workflow-starting-mutation-credential-proof.md @@ -0,0 +1,45 @@ +# Workflow-starting mutation credential proof + +Decision date: **2026-09-07** + +## Problem + +GitHub does not create a new workflow run for events generated by a workflow's +own `GITHUB_TOKEN`. A declared App or PAT source is therefore insufficient +authority: a missing secret can fall back to `github.token` while retaining an +allowlisted source label. Moving a PR head in that state creates the exact +chicken-and-egg condition the scheduler is intended to prevent—the new head +requires checks that its mutation credential cannot start. + +## Decision + +At the final mutation boundary, require all of the following: + +1. the declared source is workflow-starting; +2. the selected `GH_TOKEN` is present; +3. the workflow-token comparison value is present; and +4. the two token values differ. + +Any missing or identical evidence fails closed. The workflow supplies +`SCHEDULER_WORKFLOW_TOKEN` only to the scheduler mutation job. A recorded +withheld decision carries its own reason so later environment changes cannot +rewrite the operator explanation. + +## Failure scenes + +- A configured secret is empty and expression fallback selects + `github.token`: the mutation is withheld. +- Token comparison evidence is absent: the mutation is withheld. +- A decision is rendered after credentials rotate: the original reason remains + visible. + +## Evidence and follow-up + +The permanent RED regression is commit +`ebcc6715e68d6bd4dc78f1ce6c3e473a2dfef899`. Fresh exact-head hosted checks and +independent review remain required. + +## Reference + +GitHub. (2026). *Automatic token authentication*. +https://docs.github.com/actions/security-for-github-actions/security-guides/automatic-token-authentication diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index b5d5bb7754..9554fa6389 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -3370,3 +3370,22 @@ same name in another file can carry the opposite safety property.** [#1231](https://github.com/ContextualWisdomLab/.github/pull/1231); RED commit `8cc62ce8837e456dfac4f592bcbd0786a77e4b81`; fresh exact-head hosted checks remain required before integration. + + +### Workflow-starting mutation credential proof + +- **Status:** Proposed +- **Owner:** `ContextualWisdomLab/.github` +- **Problem:** An allowlisted credential-source label could authorize a PR head + mutation even when the selected `GH_TOKEN` was missing or had fallen back to + the workflow `github.token`, which cannot trigger the required new + current-head workflow runs. +- **Action:** Require present, distinct selected-token and workflow-token + evidence at every head-mutation boundary; preserve the original rejection + reason for later operator guidance. +- **Evidence:** RED commit + `ebcc6715e68d6bd4dc78f1ce6c3e473a2dfef899`. Exact run `34179686961`, job + `101918724013`, reports the defensive `credential_reason is None` guard at + `pr_review_merge_scheduler_core.py:497` as the only missed production + statement. The valid-source fixture now executes that fail-closed edge; + fresh exact-head hosted checks remain required before integration. diff --git a/scripts/ci/pr_review_merge_scheduler_core.py b/scripts/ci/pr_review_merge_scheduler_core.py index 9971920236..9adcac3e37 100644 --- a/scripts/ci/pr_review_merge_scheduler_core.py +++ b/scripts/ci/pr_review_merge_scheduler_core.py @@ -455,34 +455,48 @@ def mutation_token_label() -> str: return labels.get(source, "workflow GH_TOKEN") -def head_mutation_credential_starts_workflows() -> bool: - """Return whether scheduler head mutations can start required workflow runs. +def head_mutation_credential_problem() -> str | None: + """Explain why the selected mutation credential cannot start workflow runs. GitHub never creates a new workflow run for an event produced with the - workflow ``GITHUB_TOKEN``, so a PR head moved with that credential can never - collect the current-head required checks that protected branches demand - (GitHub, 2025). - - References: - GitHub. (2025). *Automatic token authentication*. - https://docs.github.com/actions/security-for-github-actions/security-guides/automatic-token-authentication + workflow GITHUB_TOKEN, so a head moved with that credential cannot + collect protected-branch current-head checks. """ - return mutation_token_source() in WORKFLOW_STARTING_MUTATION_SOURCES - - -def non_triggering_head_mutation_reason(action: str) -> str: - """Explain why a head mutation is withheld for a non-triggering credential.""" source = mutation_token_source() if source == "github-token": - credential_reason = ( - "the workflow GITHUB_TOKEN, whose head mutations never start new workflow runs" + return "the workflow GITHUB_TOKEN, whose head mutations never start new workflow runs" + if source not in WORKFLOW_STARTING_MUTATION_SOURCES: + return f"{mutation_token_label()} is not allowlisted as workflow-starting" + + selected_token = (os.environ.get("GH_TOKEN") or "").strip() + workflow_token = (os.environ.get("SCHEDULER_WORKFLOW_TOKEN") or "").strip() + if not selected_token: + return f"{mutation_token_label()} is missing and therefore not proven workflow-starting" + if not workflow_token: + return ( + "workflow GITHUB_TOKEN comparison evidence is missing, so the selected mutation " + "credential is not proven workflow-starting" ) - else: - credential_reason = ( - f"the {mutation_token_label()}, which is not allowlisted as workflow-starting" + if selected_token == workflow_token: + return ( + f"{mutation_token_label()} resolved to the workflow GITHUB_TOKEN, whose head " + "mutations never start new workflow runs" ) + return None + + +def head_mutation_credential_starts_workflows() -> bool: + """Return whether the actual scheduler mutation token can start workflow runs.""" + return head_mutation_credential_problem() is None + + +def non_triggering_head_mutation_reason(action: str) -> str: + """Explain why a head mutation is withheld for a non-triggering credential.""" + credential_reason = head_mutation_credential_problem() + if credential_reason is None: + raise RuntimeError("withheld-mutation messaging requires a non-triggering mutation credential") return ( - f"{action} withheld because the scheduler mutation credential is {credential_reason}, " + f"{action} withheld because {credential_reason}, " "so the moved head would stay permanently " "BLOCKED without current-head required checks; configure PR_REVIEW_MERGE_TOKEN, " "OPENCODE_APPROVE_TOKEN, or the OpenCode app token for the scheduler job" @@ -495,15 +509,10 @@ def require_workflow_starting_mutation_credential(action: str) -> None: raise RuntimeError(non_triggering_head_mutation_reason(action)) -def head_mutation_credential_guidance_text() -> tuple[str, str]: - """Return operator-facing summary and limit text for a withheld head mutation.""" - if mutation_token_source() == "github-token": - return ( - "The scheduler withheld a head mutation because the workflow GITHUB_TOKEN cannot start the required current-head workflow runs.", - "Moving the head with the workflow GITHUB_TOKEN would leave the PR permanently BLOCKED, so the scheduler waits instead.", - ) +def head_mutation_credential_guidance_text(withheld_reason: str) -> tuple[str, str]: + """Render operator guidance from the immutable credential decision.""" return ( - f"The scheduler withheld a head mutation because {mutation_token_label()} is not allowlisted as workflow-starting.", + f"The scheduler withheld a head mutation. Recorded decision: {withheld_reason}", "Moving the head is unsafe until the scheduler can prove that the selected credential starts the required current-head workflow runs.", ) @@ -654,7 +663,7 @@ def decision_guidance(decision: Decision) -> dict[str, Any] | None: ], } if parse_non_triggering_head_mutation_reason(decision.reason): - summary, automation_limit = head_mutation_credential_guidance_text() + summary, automation_limit = head_mutation_credential_guidance_text(decision.reason) return { "type": "head_mutation_credential_upgrade", "token": mutation_token_label(), @@ -5213,7 +5222,7 @@ def head_mutation_credential_upgrade_summary(decisions: list[Decision]) -> list[ waits = [decision for decision in decisions if parse_non_triggering_head_mutation_reason(decision.reason)] if not waits: return [] - summary, automation_limit = head_mutation_credential_guidance_text() + summary, automation_limit = head_mutation_credential_guidance_text(waits[0].reason) lines = ["", "### Head mutation withheld", "", summary, automation_limit] lines.extend( [ @@ -5232,6 +5241,8 @@ def parse_non_triggering_head_mutation_reason(reason: str) -> bool: return ( "whose head mutations never start new workflow runs" in reason or "which is not allowlisted as workflow-starting" in reason + or "is not allowlisted as workflow-starting" in reason + or "not proven workflow-starting" in reason ) @@ -5429,16 +5440,28 @@ def summarize_action_error(exc: RuntimeError) -> str: @contextlib.contextmanager def declared_mutation_token_source(source: str) -> Iterator[None]: - """Declare a scheduler mutation credential source for the enclosed block.""" - previous = os.environ.get("SCHEDULER_MUTATION_TOKEN_SOURCE") + """Declare coherent synthetic mutation-token evidence for offline self-tests.""" + keys = ( + "SCHEDULER_MUTATION_TOKEN_SOURCE", + "GH_TOKEN", + "SCHEDULER_WORKFLOW_TOKEN", + ) + previous = {key: os.environ.get(key) for key in keys} os.environ["SCHEDULER_MUTATION_TOKEN_SOURCE"] = source + os.environ["SCHEDULER_WORKFLOW_TOKEN"] = "self-test-workflow-token" + os.environ["GH_TOKEN"] = ( + "self-test-workflow-token" + if source == "github-token" + else "self-test-selected-mutation-token" + ) try: yield finally: - if previous is None: - os.environ.pop("SCHEDULER_MUTATION_TOKEN_SOURCE", None) - else: - os.environ["SCHEDULER_MUTATION_TOKEN_SOURCE"] = previous + for key, value in previous.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value def self_test() -> None: diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index ac8d40a758..84666a687e 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -34,6 +34,8 @@ def workflow_starting_mutation_credential(monkeypatch): workflow-starting credential exactly like the scheduler workflow does. """ monkeypatch.setenv("SCHEDULER_MUTATION_TOKEN_SOURCE", "PR_REVIEW_MERGE_TOKEN") + monkeypatch.setenv("GH_TOKEN", "selected-mutation-token") + monkeypatch.setenv("SCHEDULER_WORKFLOW_TOKEN", "workflow-runner-token") @pytest.fixture(autouse=True) @@ -4735,6 +4737,8 @@ def test_workflow_starting_credentials_allow_head_mutations(monkeypatch): monkeypatch.setenv("SCHEDULER_MUTATION_TOKEN_SOURCE", source) assert sched.head_mutation_credential_starts_workflows() sched.require_workflow_starting_mutation_credential("update-branch") + with pytest.raises(RuntimeError, match="withheld-mutation messaging requires"): + sched.non_triggering_head_mutation_reason("update-branch") def test_unknown_mutation_credential_source_is_fail_closed(monkeypatch): @@ -10824,3 +10828,49 @@ def fake_run_with_env(args, *, stdin=None, env=None): "target-actions-token", "target-actions-token", ] + + +@pytest.mark.parametrize( + ("selected_token", "workflow_token", "message"), + ( + ("", "workflow-runner-token", "is missing"), + ("selected-mutation-token", "", "comparison evidence is missing"), + ("workflow-runner-token", "workflow-runner-token", "resolved to"), + ), +) +def test_declared_workflow_starting_source_cannot_mask_runner_token_fallback( + monkeypatch, + selected_token, + workflow_token, + message, +): + """A declared App/PAT source cannot hide a missing or workflow-token fallback.""" + monkeypatch.setenv("SCHEDULER_MUTATION_TOKEN_SOURCE", "PR_REVIEW_MERGE_TOKEN") + monkeypatch.setenv("GH_TOKEN", selected_token) + monkeypatch.setenv("SCHEDULER_WORKFLOW_TOKEN", workflow_token) + + assert not sched.head_mutation_credential_starts_workflows() + with pytest.raises(RuntimeError, match=message): + sched.require_workflow_starting_mutation_credential("update-branch") + + +def test_withheld_mutation_guidance_uses_recorded_reason_after_environment_changes( + monkeypatch, +): + """A recorded wait decision cannot be rewritten by later credential changes.""" + monkeypatch.setenv("SCHEDULER_MUTATION_TOKEN_SOURCE", "github-token") + monkeypatch.setenv("GH_TOKEN", "workflow-runner-token") + monkeypatch.setenv("SCHEDULER_WORKFLOW_TOKEN", "workflow-runner-token") + reason = sched.non_triggering_head_mutation_reason("branch update") + + monkeypatch.setenv("SCHEDULER_MUTATION_TOKEN_SOURCE", "PR_REVIEW_MERGE_TOKEN") + monkeypatch.setenv("GH_TOKEN", "selected-mutation-token") + assert sched.head_mutation_credential_starts_workflows() + + decision = sched.Decision(7, "wait", reason) + guidance = sched.decision_guidance(decision) + assert guidance is not None + assert "workflow GITHUB_TOKEN" in guidance["summary"] + assert "workflow GITHUB_TOKEN" in "\n".join( + sched.head_mutation_credential_upgrade_summary([decision]) + )