diff --git a/.github/workflows/opencode-review-dispatch.yml b/.github/workflows/opencode-review-dispatch.yml index de1c4800d3..545a2ad7e6 100644 --- a/.github/workflows/opencode-review-dispatch.yml +++ b/.github/workflows/opencode-review-dispatch.yml @@ -179,13 +179,13 @@ jobs: if [ "$live_state" != "open" ] || [ "$live_base_repository" != "$TARGET_REPOSITORY" ] || - [ "$live_head_repository" != "$TARGET_REPOSITORY" ] || + ! [[ "$live_head_repository" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || ! [[ "$live_base_sha" =~ ^[0-9a-fA-F]{40}$ ]] || ! [[ "$live_head_sha" =~ ^[0-9a-fA-F]{40}$ ]] || ! [[ "$live_is_private" =~ ^(true|false)$ ]] || [ -z "$live_base_ref" ] || [ -z "$live_head_ref" ]; then - printf '::error::PR metadata validation rejected closed, missing, cross-repository, or malformed live metadata. target=%s#%s state=%s base_repo=%s head_repo=%s base=%s head=%s\n' "$TARGET_REPOSITORY" "$PR_NUMBER" "${live_state:-}" "${live_base_repository:-}" "${live_head_repository:-}" "${live_base_sha:-}" "${live_head_sha:-}" + printf '::error::PR metadata validation rejected closed, missing, or malformed live metadata. target=%s#%s state=%s base_repo=%s head_repo=%s base=%s head=%s\n' "$TARGET_REPOSITORY" "$PR_NUMBER" "${live_state:-}" "${live_base_repository:-}" "${live_head_repository:-}" "${live_base_sha:-}" "${live_head_sha:-}" exit 1 fi @@ -2165,7 +2165,7 @@ jobs: live_is_private="$(jq -r '.base.repo.private | tostring' <<<"$pull_request_json")" if [ "$live_state" != "open" ] || [ "$base_repository" != "$GH_REPOSITORY" ] || - [ "$head_repository" != "$GH_REPOSITORY" ] || + ! [[ "$head_repository" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || [ "$live_base_ref" != "$EXPECTED_BASE_REF" ] || [ "$live_base_sha" != "$EXPECTED_BASE_SHA" ] || [ "$live_head_ref" != "$EXPECTED_HEAD_REF" ] || @@ -2177,7 +2177,7 @@ jobs: "$GH_REPOSITORY" "$PR_NUMBER" "${live_state:-}" "${base_repository:-}" "${live_base_ref:-}" "${live_base_sha:-}" "$EXPECTED_BASE_REF" "$EXPECTED_BASE_SHA" "${head_repository:-}" "${live_head_ref:-}" "${live_head_sha:-}" "$EXPECTED_HEAD_REF" "$EXPECTED_HEAD_SHA" "${live_is_private:-}" "${EXPECTED_IS_PRIVATE:-}" exit 1 fi - printf 'Validated same-repository OpenCode review source for %s#%s (%s).\n' \ + printf 'Validated exact-head OpenCode review source for %s#%s (%s).\n' \ "$GH_REPOSITORY" "$PR_NUMBER" "$head_repository" - name: Exchange OpenCode app token for target repository review reads diff --git a/.github/workflows/pr-review-merge-scheduler.yml b/.github/workflows/pr-review-merge-scheduler.yml index 1f63a9d46f..99bd6a89b1 100644 --- a/.github/workflows/pr-review-merge-scheduler.yml +++ b/.github/workflows/pr-review-merge-scheduler.yml @@ -286,10 +286,10 @@ jobs: if [ "$live_number" != "$TARGET_PR_NUMBER" ] || [ "$live_state" != "open" ] || [ "$live_base_repository" != "$TARGET_REPOSITORY_INPUT" ] || - [ "$live_head_repository" != "$TARGET_REPOSITORY_INPUT" ] || + ! [[ "$live_head_repository" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || [ -z "$live_base_branch" ] || ! [[ "$live_head_sha" =~ ^[0-9a-fA-F]{40}$ ]]; then - printf '::error::Targeted scheduler dispatch rejected closed, cross-repository, or malformed live PR metadata. target=%s pr=%s state=%s base_repository=%s head_repository=%s base_branch=%s head_sha=%s\n' "$TARGET_REPOSITORY_INPUT" "$TARGET_PR_NUMBER" "${live_state:-}" "${live_base_repository:-}" "${live_head_repository:-}" "${live_base_branch:-}" "${live_head_sha:-}" + printf '::error::Targeted scheduler dispatch rejected closed or malformed live PR metadata. target=%s pr=%s state=%s base_repository=%s head_repository=%s base_branch=%s head_sha=%s\n' "$TARGET_REPOSITORY_INPUT" "$TARGET_PR_NUMBER" "${live_state:-}" "${live_base_repository:-}" "${live_head_repository:-}" "${live_base_branch:-}" "${live_head_sha:-}" exit 1 fi if [ -n "$TARGET_BASE_BRANCH_INPUT" ] && diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ab60b75a5..e1f19bc01a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed - Forward `trigger_reviews=true` explicitly from the trusted OpenCode mention wrapper to the authoritative scheduler while retaining GitHub's ten-key dispatch limit. Source-comment identity remains bound in the verified invocation claim and durable ledger instead of occupying an unused scheduler field, so a successfully routed `@opencode-agent` request now dispatches review work rather than entering queue maintenance with reviews disabled. +- Allowed an allowlisted base repository's open fork-head PR to enter the central exact-head OpenCode review path. The scheduler and privileged reviewer still re-read the live PR, bind base/head refs and SHAs, reject malformed repository identities, keep fork source as untrusted data, preserve the existing maintainer-writable update rule, and reserve the final external-head merge for a maintainer. - Confined OSV base and head repository checkouts to the same `source/` child directory, so a cross-fork head checkout can replace that repository without deleting the base-scan JSON held at the workspace root. Both scans retain identical source paths and the required base/head vulnerability comparison remains fail-closed. - Restored 100% docstring coverage for the commercial-readiness GitHub transport constructor. - Refused PR Review Merge Scheduler head mutations, `update-branch` and the last-push approval head restamp, whenever the resolved mutation credential is the workflow `GITHUB_TOKEN`. GitHub starts no workflow run for events created with that credential, so the moved head collected no current-head required checks and the PR stayed permanently `BLOCKED` with a `github-actions[bot]` merge commit that no later scheduler run could repair, because the branch was no longer behind. The scheduler now waits with `head_mutation_credential_upgrade` guidance naming `PR_REVIEW_MERGE_TOKEN`, `OPENCODE_APPROVE_TOKEN`, and the OpenCode app token exchange. diff --git a/docs/doctoring/fork-head-review-dispatch.md b/docs/doctoring/fork-head-review-dispatch.md new file mode 100644 index 0000000000..dddef9c203 --- /dev/null +++ b/docs/doctoring/fork-head-review-dispatch.md @@ -0,0 +1,56 @@ +# Fork-head OpenCode review dispatch + +## Decision + +An allowlisted ContextualWisdomLab base repository may dispatch an open pull +request whose head repository is a canonical `owner/repository` fork. The fork +is review data, never a trusted workflow source. The central +scheduler and OpenCode workflow continue to re-read the live pull request, +bind the base and head refs and commit SHAs, run protected default-branch +workflow code, update an external head only under the existing +maintainer-writable rule, and exclude external heads from automated merge. + +## Root cause + +Production scheduler run `32549777222` received an exact request for +`ContextualWisdomLab/contextual-orchestrator#820`, but rejected the open PR +before review because its head repository was a fork. This contradicted the +existing scheduler policy, which already classifies external heads as +reviewable while reserving their merge and update decisions for a maintainer. + +## Trust boundary + +The smallest repair removes only the false base-equals-head requirement. The +allowlisted base repository must still match the live PR, the head repository +must be a canonical GitHub repository name, and both validation passes must +observe the same exact base/head refs and SHAs. Source is fetched through the +base repository's authenticated PR boundary, materialized at the validated +commits, and handled by the existing credential-scrubbed review sandbox. No +fork workflow is loaded and no new write permission, provider credential, or +mutation or merge authority is granted. This follows GitHub's requirement to treat fork +content as untrusted and the SSDF practice of addressing a root cause without +weakening the surrounding security controls. + +## Verification + +The executable scheduler regression accepts a canonical fork head while +preserving the exact base branch and head SHA outputs, then rejects a malformed +three-component head repository. Static workflow contracts keep the live base +repository match, exact-head revalidation before OIDC/model work, canonical +head-repository validation, and the absence of the former same-repository +guard. + +## APA 7th references + +GitHub, Inc. (n.d.-a). *REST API endpoints for pull requests*. GitHub Docs. +Retrieved August 22, 2026, from +https://docs.github.com/en/rest/pulls/pulls + +GitHub, Inc. (n.d.-b). *Secure use reference*. GitHub Docs. Retrieved August +22, 2026, from +https://docs.github.com/en/actions/reference/security/secure-use + +Souppaya, M., Scarfone, K., & Dodson, D. (2022). *Secure Software Development +Framework (SSDF) version 1.1: Recommendations for mitigating the risk of +software vulnerabilities* (NIST Special Publication 800-218). National +Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218 diff --git a/docs/org-required-workflow-rollout.md b/docs/org-required-workflow-rollout.md index 9c42ab0637..3d2e1ac611 100644 --- a/docs/org-required-workflow-rollout.md +++ b/docs/org-required-workflow-rollout.md @@ -1,6 +1,6 @@ # ContextualWisdomLab central required workflow rollout -Updated: 2026-07-23 06:35 KST +Updated: 2026-08-22 KST ## Decision @@ -39,7 +39,7 @@ The central `.github/workflows/opencode-review.yml` is now part of the active or - Stable branch-protection job names: `required-workflow-bootstrap`, `coverage-source-tree`, `coverage-evidence`, and `opencode-review`; these jobs are data-only sentinels, while approval remains a separate current-head PR-review requirement - Trusted source: `ContextualWisdomLab/.github` - PR-head handling: authenticated current-head `repository_dispatch` runs `.github/workflows/opencode-review-dispatch.yml` from the protected default branch; that workflow owns metadata validation, bounded coverage, source-as-data inspection, model review, and publication -- Manual target support: the central scheduler sends exact repository, PR, base, and head metadata through `repository_dispatch`; the dispatch workflow rejects an unauthorized actor, an unallowlisted repository, a fork head, or any live metadata mismatch +- Manual target support: the central scheduler sends exact repository, PR, base, and head metadata through `repository_dispatch`; the dispatch workflow rejects an unauthorized actor, an unallowlisted base repository, a malformed head-repository identity, or any live base/head metadata mismatch. A canonical fork head remains reviewable as untrusted source data. - Model token posture: use the organization `STRIX_GITHUB_MODELS_TOKEN` secret for GitHub Models calls, with `github.token` as the fallback; live workflow evidence showed `github.token` alone can return 403 from `models.github.ai/inference` - Write posture: OpenCode may create review/comment side effects through the OpenCode app token when available; the workflow token is limited to the same-repository PR context and publication failures remain visible - Coverage execution posture: PR-controlled package, test, build, R, Rust, and Docker inputs are never executed from `pull_request_target`; the dispatch workflow runs bounded low-privilege coverage only after exact live metadata and scheduler identity validation @@ -52,8 +52,11 @@ For a bounded current-head retry in one repository, dispatch `merge-scheduler` to the central repository with `target_repository`, `pr_number`, and the live `base_branch`. The target must exactly match `OPENCODE_REPOSITORY_DISPATCH_TARGETS`; the scheduler then re-reads the open PR -and rejects a noncanonical repository name, fork head, base mismatch, malformed -head SHA, or changed/closed PR before using cross-repository credentials: +and rejects a noncanonical base or head repository name, base mismatch, +malformed head SHA, or changed/closed PR before using cross-repository +credentials. A fork head may pass this read/review boundary, but the scheduler +still excludes it from direct merge, auto-merge, and non-maintainer branch +mutation: ```bash jq -n '{ diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 379dded147..2cc8044b1b 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -2193,6 +2193,16 @@ def test_opencode_privileged_review_security_boundaries_are_fail_closed(): assert workflow.count("ref: ${{ steps.trusted_source.outputs.ref }}") == 1 assert "TRUSTED_SOURCE_REF: ${{ steps.trusted_source.outputs.ref }}" in workflow assert "ref: ${{ github.workflow_sha }}" not in workflow + metadata_step = workflow.split( + " - name: Bind workflow inputs to live organization pull request metadata", + 1, + )[1].split("\n - name:", 1)[0] + assert ( + '! [[ "$live_head_repository" =~ ' + '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]' + ) in metadata_step + assert '[ "$live_head_repository" != "$TARGET_REPOSITORY" ]' not in metadata_step + assert '[ "$SUPPLIED_HEAD_SHA" = "$live_head_sha" ]' in metadata_step trust_step = target_job.split( " - name: Validate pull request head repository trust", 1 )[1].split("\n - name:", 1)[0] @@ -2201,6 +2211,11 @@ def test_opencode_privileged_review_security_boundaries_are_fail_closed(): assert "metadata changed before OIDC" in trust_step assert 'live_head_sha="$(jq -r' in trust_step assert '[ "$live_head_sha" != "$EXPECTED_HEAD_SHA" ]' in trust_step + assert ( + '! [[ "$head_repository" =~ ' + '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]' + ) in trust_step + assert '[ "$head_repository" != "$GH_REPOSITORY" ]' not in trust_step assert ( "EXPECTED_IS_PRIVATE: " "${{ needs.validate-pr-metadata.outputs.is_private }}" diff --git a/tests/test_opencode_workflow_shell_syntax.py b/tests/test_opencode_workflow_shell_syntax.py index ec6edca40c..056b450f52 100644 --- a/tests/test_opencode_workflow_shell_syntax.py +++ b/tests/test_opencode_workflow_shell_syntax.py @@ -148,7 +148,7 @@ def test_merge_scheduler_targeted_dispatch_run_block_is_valid_bash(): def test_merge_scheduler_targeted_dispatch_validates_live_exact_pr(tmp_path): - """Only an allowlisted same-repository open PR reaches scheduler outputs.""" + """Allowlisted open PRs keep exact outputs even when their head is a fork.""" if sys.platform == "win32": return bash = shutil.which("bash") @@ -260,6 +260,35 @@ def test_merge_scheduler_targeted_dispatch_validates_live_exact_pr(tmp_path): env=cross_repo_env, ) - assert cross_repo.returncode == 1 - assert "cross-repository" in cross_repo.stdout + assert cross_repo.returncode == 0, cross_repo.stderr + assert output.read_text(encoding="utf-8").splitlines() == [ + "repository=ContextualWisdomLab/naruon", + "base_branch=develop", + "head_sha=4afd4af7ad343660356791873d940aa2846f40c2", + ] + + output.unlink() + malformed_head_env = { + **env, + "FAKE_PULL_JSON": json.dumps( + { + **cross_repo_pull, + "head": { + **cross_repo_pull["head"], + "repo": {"full_name": "outside/fork/extra"}, + }, + } + ), + } + malformed_head = subprocess.run( + [bash], + input=script, + text=True, + capture_output=True, + check=False, + env=malformed_head_env, + ) + + assert malformed_head.returncode == 1 + assert "malformed live PR metadata" in malformed_head.stdout assert not output.exists() diff --git a/tests/test_pr_review_autofix_nvidia_nim_contract.py b/tests/test_pr_review_autofix_nvidia_nim_contract.py index 335bfa13d2..222a92da31 100644 --- a/tests/test_pr_review_autofix_nvidia_nim_contract.py +++ b/tests/test_pr_review_autofix_nvidia_nim_contract.py @@ -20,7 +20,7 @@ DOCTORING_RECORD = Path("docs/doctoring/hourly-nvidia-nim-autofix.md") CHANGELOG = Path("CHANGELOG.md") REVIEW_DISPATCH_WORKFLOW = Path(".github/workflows/opencode-review-dispatch.yml") -REVIEW_DISPATCH_BLOB_SHA = "de1c4800d362bda4a90c31a0c8e39687a782afb4" +REVIEW_DISPATCH_BLOB_SHA = "545a2ad7e667220de263805d2c560752a4448347" def _workflow_text(path: Path) -> str: @@ -156,8 +156,8 @@ def test_missing_nvidia_nim_secret_fails_closed_before_model_execution() -> None assert guard in workflow[conflict_start:] -def test_independent_review_agent_key_system_is_unchanged() -> None: - """Pin the existing read-only reviewer workflow byte-for-byte.""" +def test_independent_review_agent_workflow_matches_reviewed_blob() -> None: + """Pin the reviewed read-only reviewer workflow byte-for-byte.""" result = subprocess.run( ["git", "hash-object", str(REVIEW_DISPATCH_WORKFLOW)], check=True, diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index b196c4ba62..49115b3e29 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -94,7 +94,7 @@ def test_merge_scheduler_provides_same_repository_dispatch_credential() -> None: def test_targeted_scheduler_dispatch_is_allowlisted_and_exact_pr_scoped() -> None: - """Central single-PR dispatch must validate live metadata before cross-repo use.""" + """Central single-PR dispatch accepts a bounded fork head without trusting it.""" workflow = workflow_text("pr-review-merge-scheduler.yml") validation = workflow_step(workflow, "Validate targeted repository dispatch") inspect = workflow_step(workflow, "Inspect PR review and merge queue") @@ -111,7 +111,11 @@ def test_targeted_scheduler_dispatch_is_allowlisted_and_exact_pr_scoped() -> Non assert '"repos/${TARGET_REPOSITORY_INPUT}/pulls/${TARGET_PR_NUMBER}"' in validation assert '[ "$live_state" != "open" ]' in validation assert '[ "$live_base_repository" != "$TARGET_REPOSITORY_INPUT" ]' in validation - assert '[ "$live_head_repository" != "$TARGET_REPOSITORY_INPUT" ]' in validation + assert ( + '! [[ "$live_head_repository" =~ ' + '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]' + ) in validation + assert '[ "$live_head_repository" != "$TARGET_REPOSITORY_INPUT" ]' not in validation assert "Targeted scheduler dispatch base branch does not match the live PR" in validation assert "TARGET_REPOSITORY: ${{ steps.targeted_dispatch.outputs.repository }}" in inspect assert (