From 932b58d313637fee3b05a7e6874ef23f3d41e503 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 1 Aug 2026 12:12:00 +0900 Subject: [PATCH 1/3] fix(review): expose cross-repository dispatch failures --- .../workflows/opencode-review-dispatch.yml | 133 +++++++++++---- README.md | 10 ++ scripts/ci/opencode_dispatch_status.py | 133 +++++++++++++-- tests/test_opencode_agent_contract.py | 55 +++++-- tests/test_opencode_security_boundaries.py | 153 ++++++++++++++++++ 5 files changed, 433 insertions(+), 51 deletions(-) diff --git a/.github/workflows/opencode-review-dispatch.yml b/.github/workflows/opencode-review-dispatch.yml index d826ce67a..61931dda3 100644 --- a/.github/workflows/opencode-review-dispatch.yml +++ b/.github/workflows/opencode-review-dispatch.yml @@ -7175,6 +7175,11 @@ jobs: printf '::warning::Open code-scanning alert lookup skipped because no target-repository read token was configured.\n' >&2 return 1 fi + if [ "${GH_REPOSITORY:-}" != "${GITHUB_REPOSITORY:-}" ] && + [ "${CODE_SCANNING_TOKEN_SOURCE:-}" = "github-token" ]; then + printf '::warning::Open code-scanning alert lookup skipped because the central github.token cannot read cross-repository target %s. Configure PR_REVIEW_MERGE_TOKEN or OPENCODE_APPROVE_TOKEN with target code-scanning read access.\n' "$GH_REPOSITORY" >&2 + return 1 + fi lookup_error_file="$(mktemp)" if ! pr_json="$(GH_TOKEN="$scan_token" timeout "$(check_lookup_api_timeout_seconds)s" \ gh pr view "$PR_NUMBER" --repo "$GH_REPOSITORY" --json headRefName 2>"$lookup_error_file")"; then @@ -7826,14 +7831,16 @@ jobs: && needs.validate-pr-metadata.outputs.target_repository != '' && needs.validate-pr-metadata.outputs.head_sha != '' env: - GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.opencode_app_token.outputs.token || github.token }} + OPENCODE_STATUS_GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || github.token }} + OPENCODE_READ_GH_TOKEN: ${{ steps.opencode_app_token.outputs.token || secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || github.token }} + OPENCODE_COMMENT_GH_TOKEN: ${{ steps.opencode_app_token.outputs.token }} GH_REPOSITORY: ${{ needs.validate-pr-metadata.outputs.target_repository }} PR_NUMBER: ${{ needs.validate-pr-metadata.outputs.pr_number }} PR_HEAD_SHA: ${{ needs.validate-pr-metadata.outputs.head_sha }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} OPENCODE_MODEL_POOL_OUTCOME: ${{ steps.opencode_review_model_pool.outputs.review_status }} COVERAGE_EVIDENCE_RESULT: ${{ needs.coverage-evidence.result }} - OPENCODE_STATUS_TOKEN_SOURCE: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' && 'PR_REVIEW_MERGE_TOKEN' || secrets.OPENCODE_APPROVE_TOKEN != '' && 'OPENCODE_APPROVE_TOKEN' || steps.opencode_app_token.outputs.available == 'true' && 'opencode-app' || 'github-token' }} + OPENCODE_STATUS_TOKEN_SOURCE: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' && 'PR_REVIEW_MERGE_TOKEN' || secrets.OPENCODE_APPROVE_TOKEN != '' && 'OPENCODE_APPROVE_TOKEN' || 'github-token' }} OPENCODE_CHANGED_FILES_FILE: ${{ runner.temp }}/opencode-changed-files.txt OPENCODE_ARTIFACT_MANIFEST_SHA256: ${{ steps.seal_artifacts.outputs.manifest_sha256 }} OPENCODE_SOURCE_WORKDIR: ${{ runner.temp }}/opencode-pr-head @@ -7844,44 +7851,112 @@ jobs: echo "::error::OpenCode repository_dispatch status publication failed because pr_head_sha was empty." exit 1 fi - if [ "${GH_REPOSITORY:-}" != "${GITHUB_REPOSITORY:-}" ] && - [ "${OPENCODE_STATUS_TOKEN_SOURCE:-}" = "github-token" ]; then - echo "::notice::OpenCode repository_dispatch status publication is unavailable because only the same-repository github.token can access cross-repository target ${GH_REPOSITORY}. The exact-head formal review remains authoritative; configure PR_REVIEW_MERGE_TOKEN or OPENCODE_APPROVE_TOKEN to publish the optional commit status." - exit 0 - fi state="failure" description="OpenCode live approval evidence validation failed." pull_request_file="$(mktemp)" reviews_file="$(mktemp)" + comments_file="$(mktemp)" + visibility_body_file="$(mktemp)" + visibility_payload_file="$(mktemp)" + status_error_file="$(mktemp)" cleanup_status_evidence() { - rm -f "$pull_request_file" "$reviews_file" + rm -f \ + "$pull_request_file" \ + "$reviews_file" \ + "$comments_file" \ + "$visibility_body_file" \ + "$visibility_payload_file" \ + "$status_error_file" } trap cleanup_status_evidence EXIT - if gh api "repos/${GH_REPOSITORY}/pulls/${PR_NUMBER}" >"$pull_request_file" && - gh api "repos/${GH_REPOSITORY}/pulls/${PR_NUMBER}/reviews" --paginate --slurp \ - | jq 'flatten' >"$reviews_file"; then - decision_json="$( - python3 scripts/ci/opencode_dispatch_status.py \ - --model-outcome "${OPENCODE_MODEL_POOL_OUTCOME:-missing}" \ - --coverage-result "${COVERAGE_EVIDENCE_RESULT:-missing}" \ - --expected-head "$PR_HEAD_SHA" \ - --pull-request-file "$pull_request_file" \ - --reviews-file "$reviews_file" - )" - state="$(jq -r '.state // "failure"' <<<"$decision_json")" - description="$(jq -r '.description // "OpenCode live approval evidence validation failed."' <<<"$decision_json")" - else - echo "::error::OpenCode repository_dispatch status could not read the live pull request and complete review history; publishing failure." + if [ -z "${OPENCODE_READ_GH_TOKEN:-}" ]; then + echo "::error::OpenCode repository_dispatch evidence validation has no target-repository read token." + exit 1 + fi + if ! GH_TOKEN="$OPENCODE_READ_GH_TOKEN" \ + gh api "repos/${GH_REPOSITORY}/pulls/${PR_NUMBER}" >"$pull_request_file" || + ! GH_TOKEN="$OPENCODE_READ_GH_TOKEN" \ + gh api "repos/${GH_REPOSITORY}/pulls/${PR_NUMBER}/reviews" --paginate --slurp \ + | jq 'flatten' >"$reviews_file" || + ! GH_TOKEN="$OPENCODE_READ_GH_TOKEN" \ + gh api "repos/${GH_REPOSITORY}/issues/${PR_NUMBER}/comments" --paginate --slurp \ + | jq 'flatten' >"$comments_file"; then + echo "::error::OpenCode repository_dispatch status could not read the live pull request, complete review history, and visibility comments." + exit 1 fi - printf 'Publishing OpenCode repository_dispatch status context opencode-review for %s at %s with state=%s using %s token.\n' "$GH_REPOSITORY" "$PR_HEAD_SHA" "$state" "${OPENCODE_STATUS_TOKEN_SOURCE:-configured}" - gh api -X POST "repos/${GH_REPOSITORY}/statuses/${PR_HEAD_SHA}" \ - -f state="$state" \ - -f context="opencode-review" \ - -f target_url="$RUN_URL" \ - -f description="$description" >/dev/null + decision_json="$( + python3 scripts/ci/opencode_dispatch_status.py \ + --model-outcome "${OPENCODE_MODEL_POOL_OUTCOME:-missing}" \ + --coverage-result "${COVERAGE_EVIDENCE_RESULT:-missing}" \ + --expected-head "$PR_HEAD_SHA" \ + --pull-request-file "$pull_request_file" \ + --reviews-file "$reviews_file" \ + --comments-file "$comments_file" \ + --run-url "$RUN_URL" + )" + state="$(jq -r '.state // "failure"' <<<"$decision_json")" + description="$(jq -r '.description // "OpenCode live approval evidence validation failed."' <<<"$decision_json")" + visibility_comment_id="$(jq -r '.visibility.comment_id // empty' <<<"$decision_json")" + visibility_should_publish="$(jq -r '.visibility.should_publish // false' <<<"$decision_json")" + jq -r '.visibility.body // empty' <<<"$decision_json" >"$visibility_body_file" + + status_capable=true + if [ -z "${OPENCODE_STATUS_GH_TOKEN:-}" ]; then + status_capable=false + echo "::notice::OpenCode repository_dispatch commit-status publication is unavailable because no status token is configured." + elif [ "${GH_REPOSITORY:-}" != "${GITHUB_REPOSITORY:-}" ] && + [ "${OPENCODE_STATUS_TOKEN_SOURCE:-}" = "github-token" ]; then + status_capable=false + echo "::notice::OpenCode repository_dispatch commit-status publication is unavailable because the central github.token cannot access cross-repository target ${GH_REPOSITORY}. Configure PR_REVIEW_MERGE_TOKEN or OPENCODE_APPROVE_TOKEN for the optional commit status; the App-authored exact-head PR receipt remains authoritative for review-tool visibility." + fi + + status_published=false + if [ "$status_capable" = true ]; then + printf 'Publishing OpenCode repository_dispatch status context opencode-review for %s at %s with state=%s using %s token.\n' "$GH_REPOSITORY" "$PR_HEAD_SHA" "$state" "${OPENCODE_STATUS_TOKEN_SOURCE:-configured}" + if GH_TOKEN="$OPENCODE_STATUS_GH_TOKEN" \ + gh api -X POST "repos/${GH_REPOSITORY}/statuses/${PR_HEAD_SHA}" \ + -f state="$state" \ + -f context="opencode-review" \ + -f target_url="$RUN_URL" \ + -f description="$description" >/dev/null 2>"$status_error_file"; then + status_published=true + else + status_error="$(head -n 1 "$status_error_file" | tr -d '\r' || true)" + echo "::warning::OpenCode repository_dispatch commit-status publication failed using ${OPENCODE_STATUS_TOKEN_SOURCE:-configured}; the App-authored exact-head PR receipt will expose the review-tool result. ${status_error:-GitHub returned no error detail.}" + fi + fi + + if [ "$visibility_should_publish" = true ]; then + if [ -z "${OPENCODE_COMMENT_GH_TOKEN:-}" ]; then + echo "::error::OpenCode cannot publish the exact-head PR visibility receipt because the OpenCode App token is unavailable." + exit 1 + fi + live_head="$( + GH_TOKEN="$OPENCODE_COMMENT_GH_TOKEN" \ + gh api "repos/${GH_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.head.sha // empty' + )" + if [ "$live_head" != "$PR_HEAD_SHA" ]; then + echo "::error::OpenCode refused stale PR visibility publication: expected head ${PR_HEAD_SHA}, observed ${live_head:-}." + exit 1 + fi + jq -n --rawfile body "$visibility_body_file" '{body: $body}' >"$visibility_payload_file" + if [ -n "$visibility_comment_id" ]; then + GH_TOKEN="$OPENCODE_COMMENT_GH_TOKEN" \ + gh api -X PATCH "repos/${GH_REPOSITORY}/issues/comments/${visibility_comment_id}" \ + --input "$visibility_payload_file" >/dev/null + echo "Updated OpenCode App exact-head PR visibility receipt comment ${visibility_comment_id} with state=${state}." + else + GH_TOKEN="$OPENCODE_COMMENT_GH_TOKEN" \ + gh api -X POST "repos/${GH_REPOSITORY}/issues/${PR_NUMBER}/comments" \ + --input "$visibility_payload_file" >/dev/null + echo "Published OpenCode App exact-head PR visibility receipt with state=${state}." + fi + elif [ "$status_published" != true ]; then + echo "::notice::OpenCode commit status was unavailable, but the exact head already has a validated formal approval and no unresolved review-tool receipt requires an update." + fi - name: Dispatch Noema after current-head OpenCode approval if: >- diff --git a/README.md b/README.md index 332324301..f447870ba 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,16 @@ its own `pull_request_target` job token to repository-write permission; its immediate post-approval scheduler follow-up uses only an explicit merge token or the OpenCode app token, otherwise it leaves the separate scheduler required workflow and schedule authoritative. +The exchanged OpenCode App token is the identity boundary for formal reviews +and review-tool visibility comments. It is not assumed to have commit-status or +code-scanning permissions. Cross-repository `opencode-review` commit statuses +and model-unavailable code-scanning lookups therefore require +`PR_REVIEW_MERGE_TOKEN` or `OPENCODE_APPROVE_TOKEN` with access to the target +repository (commit statuses write and code-scanning alerts read, +respectively). When that optional status capability is unavailable, the central +workflow upserts an App-authored, exact-head PR receipt for failed review-tool +outcomes; the receipt never grants approval and is resolved only after a +validated exact-head OpenCode approval exists. Post-approval reuse and follow-up accept only an exact-head review authored by the OpenCode GitHub App; a GitHub Actions-authored review is not OpenCode approval evidence. The separate scheduler also listens for that App review, diff --git a/scripts/ci/opencode_dispatch_status.py b/scripts/ci/opencode_dispatch_status.py index 9109a0248..043c83a1f 100644 --- a/scripts/ci/opencode_dispatch_status.py +++ b/scripts/ci/opencode_dispatch_status.py @@ -5,6 +5,7 @@ import argparse import json +import re from pathlib import Path from typing import Any, Sequence @@ -20,6 +21,11 @@ ) +VISIBILITY_COMMENT_MARKER = "" +VISIBILITY_COMMENT_AUTHORS = frozenset({"opencode-agent", "opencode-agent[bot]"}) +SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$") + + def _has_current_approval(reviews: Sequence[dict[str, Any]], head_sha: str) -> bool: """Return whether the latest OpenCode decision is a verified approval.""" for review in reversed(reviews): @@ -66,6 +72,101 @@ def decide_status( return {"state": "failure", "description": reason} +def _bounded_field(value: str, *, fallback: str, limit: int = 240) -> str: + """Return a one-line bounded field safe for a governance receipt.""" + normalized = " ".join(str(value or "").split()) + return (normalized or fallback)[:limit] + + +def existing_visibility_comment_id(comments: Sequence[dict[str, Any]]) -> int | None: + """Return the latest OpenCode App visibility-comment id, if present.""" + for comment in reversed(comments): + author = str((comment.get("user") or {}).get("login") or "").casefold() + if author not in VISIBILITY_COMMENT_AUTHORS: + continue + if VISIBILITY_COMMENT_MARKER not in str(comment.get("body") or ""): + continue + comment_id = comment.get("id") + if isinstance(comment_id, bool) or not isinstance(comment_id, int) or comment_id < 1: + continue + return comment_id + return None + + +def visibility_comment( + *, + state: str, + description: str, + expected_head: str, + model_outcome: str, + coverage_result: str, + run_url: str, +) -> str: + """Render a bounded exact-head review-tool receipt for the target PR.""" + if not SHA_RE.fullmatch(expected_head): + raise ValueError("visibility receipt requires a 40-character head SHA") + result = "RESOLVED" if state == "success" else "REVIEW_TOOL_FAILURE" + reason = _bounded_field( + description, + fallback="OpenCode live approval evidence validation failed.", + ) + model = _bounded_field(model_outcome, fallback="missing", limit=80) + coverage = _bounded_field(coverage_result, fallback="missing", limit=80) + run = _bounded_field(run_url, fallback="unavailable", limit=300) + posture = ( + "A validated exact-current-head OpenCode approval is now present." + if state == "success" + else ( + "This is review-tool evidence, not a source finding or a clean review. " + "Merge policy remains fail closed until an exact-head formal approval exists." + ) + ) + return "\n".join( + [ + VISIBILITY_COMMENT_MARKER, + "", + "## OpenCode central review receipt", + "", + f"- Result: `{result}`", + f"- Head SHA: `{expected_head}`", + f"- Workflow run: {run}", + f"- Model-pool outcome: `{model}`", + f"- Coverage evidence: `{coverage}`", + f"- Reason: {reason}", + "", + posture, + ] + ) + + +def add_visibility_receipt( + decision: dict[str, str], + *, + comments: Sequence[dict[str, Any]], + expected_head: str, + model_outcome: str, + coverage_result: str, + run_url: str, +) -> dict[str, Any]: + """Attach an App-comment upsert decision to a commit-status decision.""" + enriched: dict[str, Any] = dict(decision) + comment_id = existing_visibility_comment_id(comments) + state = decision.get("state", "failure") + enriched["visibility"] = { + "comment_id": comment_id, + "should_publish": state != "success" or comment_id is not None, + "body": visibility_comment( + state=state, + description=decision.get("description", ""), + expected_head=expected_head, + model_outcome=model_outcome, + coverage_result=coverage_result, + run_url=run_url, + ), + } + return enriched + + def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: """Parse commit-status evidence inputs.""" parser = argparse.ArgumentParser(description=__doc__) @@ -74,6 +175,8 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: parser.add_argument("--expected-head", required=True) parser.add_argument("--pull-request-file", required=True, type=Path) parser.add_argument("--reviews-file", required=True, type=Path) + parser.add_argument("--comments-file", type=Path) + parser.add_argument("--run-url", default="") return parser.parse_args(argv) @@ -84,18 +187,26 @@ def main(argv: Sequence[str] | None = None) -> int: reviews = json.loads(args.reviews_file.read_text(encoding="utf-8")) if not isinstance(pull_request, dict) or not isinstance(reviews, list): raise SystemExit("pull request evidence must be an object and reviews evidence an array") - print( - json.dumps( - decide_status( - model_outcome=args.model_outcome, - coverage_result=args.coverage_result, - expected_head=args.expected_head, - pull_request=pull_request, - reviews=reviews, - ), - separators=(",", ":"), - ) + decision: dict[str, Any] = decide_status( + model_outcome=args.model_outcome, + coverage_result=args.coverage_result, + expected_head=args.expected_head, + pull_request=pull_request, + reviews=reviews, ) + if args.comments_file is not None: + comments = json.loads(args.comments_file.read_text(encoding="utf-8")) + if not isinstance(comments, list): + raise SystemExit("comments evidence must be an array") + decision = add_visibility_receipt( + decision, + comments=comments, + expected_head=args.expected_head, + model_outcome=args.model_outcome, + coverage_result=args.coverage_result, + run_url=args.run_url, + ) + print(json.dumps(decision, separators=(",", ":"))) return 0 diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 565ea4b9a..fb8edcffc 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1475,6 +1475,12 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): "Open code-scanning alert lookup skipped because no target-repository read token" in workflow ) + assert ( + "central github.token cannot read cross-repository target %s" in workflow + ) + assert ( + '[ "${CODE_SCANNING_TOKEN_SOURCE:-}" = "github-token" ]' in workflow + ) assert "production source 또는 package manifest 변경이 없습니다" not in workflow assert "needs.coverage-evidence.result != 'cancelled'" in workflow assert "request_changes_for_coverage_evidence_failure" in workflow @@ -2032,31 +2038,58 @@ def test_opencode_runs_merge_scheduler_after_review_without_repo_local_dispatch( " - name: Dispatch Noema after current-head OpenCode approval", 1 )[0] assert ( - "GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || " - "secrets.OPENCODE_APPROVE_TOKEN || steps.opencode_app_token.outputs.token || " - "github.token }}" + "OPENCODE_STATUS_GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || " + "secrets.OPENCODE_APPROVE_TOKEN || github.token }}" ) in status_step + status_token_line = next( + line for line in status_step.splitlines() if "OPENCODE_STATUS_GH_TOKEN:" in line + ) + assert "opencode_app_token" not in status_token_line + assert ( + "OPENCODE_COMMENT_GH_TOKEN: " + "${{ steps.opencode_app_token.outputs.token }}" in status_step + ) + assert ( + "OPENCODE_READ_GH_TOKEN: " + "${{ steps.opencode_app_token.outputs.token || " + "secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || " + "github.token }}" in status_step + ) assert "OPENCODE_STATUS_TOKEN_SOURCE" in status_step - assert "steps.opencode_app_token.outputs.available == 'true' && 'opencode-app'" in status_step + status_source_line = next( + line for line in status_step.splitlines() if "OPENCODE_STATUS_TOKEN_SOURCE:" in line + ) + assert "opencode-app" not in status_source_line assert "OPENCODE_CHANGED_FILES_FILE" in status_step assert "OPENCODE_ARTIFACT_MANIFEST_SHA256" in status_step assert "OPENCODE_SOURCE_WORKDIR" in status_step assert 'OPENCODE_REQUIRE_ADVERSARIAL_VALIDATION: "true"' in status_step assert "continue-on-error: true" not in status_step assert ( - "same-repository github.token can access cross-repository target" + "central github.token cannot access cross-repository target" in status_step ) assert "status publication failed because pr_head_sha was empty" in status_step assert "exit 1" in status_step - cross_repository_guard = status_step.split( - 'if [ "${GH_REPOSITORY:-}" != "${GITHUB_REPOSITORY:-}" ]', 1 - )[1].split("\n fi", 1)[0] - assert "exact-head formal review remains authoritative" in cross_repository_guard - assert "exit 0" in cross_repository_guard - assert "exit 1" not in cross_repository_guard assert "using %s token" in status_step assert "scripts/ci/opencode_dispatch_status.py" in status_step + assert "--comments-file" in status_step + assert "--run-url" in status_step + assert "visibility.should_publish" in status_step + assert "OPENCODE_COMMENT_GH_TOKEN" in status_step + assert "issues/${PR_NUMBER}/comments" in status_step + assert "issues/comments/${visibility_comment_id}" in status_step + assert "refused stale PR visibility publication" in status_step + assert "App-authored exact-head PR receipt" in status_step + assert 'status_error="$(head -n 1 "$status_error_file"' in status_step + assert ( + 'if GH_TOKEN="$OPENCODE_STATUS_GH_TOKEN" \\\n' + ' gh api -X POST "repos/${GH_REPOSITORY}/statuses/${PR_HEAD_SHA}"' + in status_step + ) + assert status_step.index("commit-status publication failed using") < status_step.index( + 'if [ "$visibility_should_publish" = true ]' + ) assert "COVERAGE_EVIDENCE_RESULT" in status_step assert 'gh api "repos/${GH_REPOSITORY}/pulls/${PR_NUMBER}"' in status_step assert 'gh api "repos/${GH_REPOSITORY}/pulls/${PR_NUMBER}/reviews"' in status_step diff --git a/tests/test_opencode_security_boundaries.py b/tests/test_opencode_security_boundaries.py index 1b22706fa..1013f56ef 100644 --- a/tests/test_opencode_security_boundaries.py +++ b/tests/test_opencode_security_boundaries.py @@ -495,3 +495,156 @@ def test_dispatch_status_cli_and_evidence_shape_validation( with pytest.raises(SystemExit) as exc: runpy.run_path("scripts/ci/opencode_dispatch_status.py", run_name="__main__") assert exc.value.code == 0 + + +def test_dispatch_status_visibility_receipt_is_bounded_and_app_owned() -> None: + """Review-tool failures produce one exact-head App-comment upsert receipt.""" + head = "a" * 40 + decision = { + "state": "failure", + "description": "No validated approval.\nProvider detail must remain one line.", + } + comments = [ + { + "id": 10, + "user": {"login": "pull-request-author"}, + "body": dispatch_status.VISIBILITY_COMMENT_MARKER, + }, + { + "id": 11, + "user": {"login": "opencode-agent[bot]"}, + "body": dispatch_status.VISIBILITY_COMMENT_MARKER, + }, + ] + + enriched = dispatch_status.add_visibility_receipt( + decision, + comments=comments, + expected_head=head, + model_outcome="exhausted", + coverage_result="success", + run_url="https://github.com/ContextualWisdomLab/.github/actions/runs/123", + ) + + visibility = enriched["visibility"] + assert visibility["comment_id"] == 11 + assert visibility["should_publish"] is True + assert "REVIEW_TOOL_FAILURE" in visibility["body"] + assert f"Head SHA: `{head}`" in visibility["body"] + assert "not a source finding or a clean review" in visibility["body"] + assert "Provider detail must remain one line." in visibility["body"] + + +def test_dispatch_status_visibility_comment_lookup_ignores_untrusted_shapes() -> None: + """Only a positive-id App-authored marker may be updated.""" + comments = [ + { + "id": 13, + "user": {"login": "opencode-agent"}, + "body": dispatch_status.VISIBILITY_COMMENT_MARKER, + }, + { + "id": True, + "user": {"login": "opencode-agent[bot]"}, + "body": dispatch_status.VISIBILITY_COMMENT_MARKER, + }, + { + "id": 14, + "user": {"login": "opencode-agent[bot]"}, + "body": "unrelated App comment", + }, + { + "id": 15, + "user": {"login": "pull-request-author"}, + "body": dispatch_status.VISIBILITY_COMMENT_MARKER, + }, + ] + + assert dispatch_status.existing_visibility_comment_id(comments) == 13 + + +def test_dispatch_status_visibility_receipt_resolves_only_an_existing_comment() -> None: + """Success updates a prior failure receipt without creating a new comment.""" + head = "a" * 40 + decision = { + "state": "success", + "description": "Validated current-head OpenCode approval and coverage passed.", + } + + without_prior = dispatch_status.add_visibility_receipt( + decision, + comments=[], + expected_head=head, + model_outcome="success", + coverage_result="success", + run_url="https://github.com/ContextualWisdomLab/.github/actions/runs/124", + ) + with_prior = dispatch_status.add_visibility_receipt( + decision, + comments=[ + { + "id": 12, + "user": {"login": "opencode-agent"}, + "body": dispatch_status.VISIBILITY_COMMENT_MARKER, + } + ], + expected_head=head, + model_outcome="success", + coverage_result="success", + run_url="https://github.com/ContextualWisdomLab/.github/actions/runs/124", + ) + + assert without_prior["visibility"]["should_publish"] is False + assert with_prior["visibility"]["should_publish"] is True + assert with_prior["visibility"]["comment_id"] == 12 + assert "RESOLVED" in with_prior["visibility"]["body"] + + +def test_dispatch_status_visibility_receipt_rejects_invalid_head() -> None: + """A malformed or stale identity cannot become a target-PR receipt.""" + with pytest.raises(ValueError, match="40-character head SHA"): + dispatch_status.visibility_comment( + state="failure", + description="failure", + expected_head="not-a-sha", + model_outcome="exhausted", + coverage_result="success", + run_url="https://github.com/ContextualWisdomLab/.github/actions/runs/125", + ) + + +def test_dispatch_status_cli_emits_and_validates_visibility_evidence( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """The CLI emits a receipt and rejects a non-array comments snapshot.""" + head = "b" * 40 + pr_file = tmp_path / "pr.json" + reviews_file = tmp_path / "reviews.json" + comments_file = tmp_path / "comments.json" + pr_file.write_text(json.dumps({"head": {"sha": head}}), encoding="utf-8") + reviews_file.write_text("[]", encoding="utf-8") + comments_file.write_text("[]", encoding="utf-8") + args = [ + "--model-outcome", + "exhausted", + "--coverage-result", + "success", + "--expected-head", + head, + "--pull-request-file", + str(pr_file), + "--reviews-file", + str(reviews_file), + "--comments-file", + str(comments_file), + "--run-url", + "https://github.com/ContextualWisdomLab/.github/actions/runs/126", + ] + + assert dispatch_status.main(args) == 0 + assert json.loads(capsys.readouterr().out)["visibility"]["should_publish"] is True + + comments_file.write_text("{}", encoding="utf-8") + with pytest.raises(SystemExit, match="comments evidence must be an array"): + dispatch_status.main(args) From 0f5394a6c776e465fbdaf1afd21a30a1f6afa269 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 1 Aug 2026 12:18:39 +0900 Subject: [PATCH 2/3] docs(review): clarify failure receipt semantics --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f447870ba..7e6c8d047 100644 --- a/README.md +++ b/README.md @@ -33,10 +33,10 @@ code-scanning permissions. Cross-repository `opencode-review` commit statuses and model-unavailable code-scanning lookups therefore require `PR_REVIEW_MERGE_TOKEN` or `OPENCODE_APPROVE_TOKEN` with access to the target repository (commit statuses write and code-scanning alerts read, -respectively). When that optional status capability is unavailable, the central -workflow upserts an App-authored, exact-head PR receipt for failed review-tool -outcomes; the receipt never grants approval and is resolved only after a -validated exact-head OpenCode approval exists. +respectively). For every failed review-tool outcome, the central workflow +upserts an App-authored, exact-head PR receipt whether or not the optional +commit status was published; the receipt never grants approval and is resolved +only after a validated exact-head OpenCode approval exists. Post-approval reuse and follow-up accept only an exact-head review authored by the OpenCode GitHub App; a GitHub Actions-authored review is not OpenCode approval evidence. The separate scheduler also listens for that App review, From 878df54b7547eea0e9a140cb2ff2f3f9a480f02f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 1 Aug 2026 12:58:35 +0900 Subject: [PATCH 3/3] fix(review): clarify coverage launcher symlink validation --- .github/workflows/opencode-review-dispatch.yml | 13 +++++++++++-- tests/test_opencode_agent_contract.py | 9 ++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.github/workflows/opencode-review-dispatch.yml b/.github/workflows/opencode-review-dispatch.yml index 61931dda3..d797d2f2e 100644 --- a/.github/workflows/opencode-review-dispatch.yml +++ b/.github/workflows/opencode-review-dispatch.yml @@ -582,7 +582,16 @@ jobs: if [ "${OPENCODE_COVERAGE_SANDBOXED:-0}" != "1" ]; then host_github_output="$GITHUB_OUTPUT" sandbox_result_dir="${RUNNER_TEMP}/opencode-coverage-sandbox-result" - measure_step_script="$(realpath "$0")" + # GitHub Actions invokes this generated step as `bash -e {0}`, so + # $0 is the runner-generated script path. Check that unresolved + # invocation path before realpath; checking only the resolved path + # cannot detect a symlink used to invoke the step. + measure_step_invocation="$0" + if [ -L "$measure_step_invocation" ]; then + echo "::error::Coverage sandbox launcher invocation must not be a symlink." + exit 1 + fi + measure_step_script="$(realpath "$measure_step_invocation")" case "$measure_step_script" in "${RUNNER_TEMP}"/*) ;; *) @@ -590,7 +599,7 @@ jobs: exit 1 ;; esac - if [ ! -f "$measure_step_script" ] || [ -L "$0" ] || [ "$(stat -c '%u' "$measure_step_script")" != "$(id -u)" ]; then + if [ ! -f "$measure_step_script" ] || [ -L "$measure_step_script" ] || [ "$(stat -c '%u' "$measure_step_script")" != "$(id -u)" ]; then echo "::error::Coverage sandbox launcher failed regular-file, symlink, or ownership validation." exit 1 fi diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index fb8edcffc..e053bf002 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -684,7 +684,14 @@ def test_opencode_target_coverage_materializes_only_after_authorized_dispatch(): assert "--pid private" not in measure_step assert "--pid host" not in measure_step assert "Docker's default private PID namespace" in measure_step - assert 'measure_step_script="$(realpath "$0")"' in measure_step + assert 'measure_step_invocation="$0"' in measure_step + assert '[ -L "$measure_step_invocation" ]' in measure_step + assert 'measure_step_script="$(realpath "$measure_step_invocation")"' in measure_step + assert '[ -L "$measure_step_script" ]' in measure_step + assert '[ -L "$0" ]' not in measure_step + assert measure_step.index('[ -L "$measure_step_invocation" ]') < measure_step.index( + 'measure_step_script="$(realpath "$measure_step_invocation")"' + ) assert ( "source=${measure_step_script},target=/trusted-measure-step.sh,readonly" in measure_step