diff --git a/.github/workflows/opencode-review-dispatch.yml b/.github/workflows/opencode-review-dispatch.yml index dd65d90e1..6c4c50b11 100644 --- a/.github/workflows/opencode-review-dispatch.yml +++ b/.github/workflows/opencode-review-dispatch.yml @@ -222,6 +222,9 @@ jobs: permissions: contents: read id-token: write + outputs: + coverage_source_artifact_id: ${{ steps.coverage_source_upload.outputs.artifact-id }} + coverage_source_run_attempt: ${{ steps.coverage_source_attempt.outputs.run_attempt }} env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: @@ -349,10 +352,23 @@ jobs: git -C "$COVERAGE_SOURCE_WORKDIR" status --short tar -cf "$COVERAGE_SOURCE_ARCHIVE" -C "$COVERAGE_SOURCE_WORKDIR" . + - name: Record coverage source workflow attempt + id: coverage_source_attempt + env: + GITHUB_RUN_ATTEMPT: ${{ github.run_attempt }} + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + if ! [[ "$GITHUB_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::Coverage producer workflow attempt is not a positive integer." + exit 1 + fi + printf 'run_attempt=%s\n' "$GITHUB_RUN_ATTEMPT" >>"$GITHUB_OUTPUT" + - name: Upload materialized pull request merge tree + id: coverage_source_upload uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: opencode-coverage-source + name: opencode-coverage-source-${{ github.run_attempt }} path: ${{ runner.temp }}/opencode-coverage-source.tar if-no-files-found: error retention-days: 1 @@ -431,14 +447,55 @@ jobs: if: needs.coverage-source-tree.result != 'success' run: | echo "::error::Coverage source tree could not be materialized; see the coverage-source-tree job log for the exact target repository, base SHA, head SHA, and fetch or merge failure." - exit 1 + # Continue to the unified current-attempt recovery gate for bounded + # fail-closed guidance. - - name: Download materialized pull request merge tree + - name: Verify coverage source identity for current workflow attempt + if: always() + id: coverage_source_identity + continue-on-error: true + env: + COVERAGE_SOURCE_ARTIFACT_ID: ${{ needs.coverage-source-tree.outputs.coverage_source_artifact_id }} + COVERAGE_SOURCE_RUN_ATTEMPT: ${{ needs.coverage-source-tree.outputs.coverage_source_run_attempt }} + CURRENT_RUN_ATTEMPT: ${{ github.run_attempt }} + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + if ! [[ "$CURRENT_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]] || \ + [ "$COVERAGE_SOURCE_RUN_ATTEMPT" != "$CURRENT_RUN_ATTEMPT" ]; then + echo "::error::Coverage source was not produced in current workflow attempt ${CURRENT_RUN_ATTEMPT:-missing}; producer attempt=${COVERAGE_SOURCE_RUN_ATTEMPT:-missing}." + echo "::error::Use a full rerun or a fresh repository dispatch; failed-jobs-only reruns cannot reuse prior-attempt source evidence." + exit 1 + fi + if ! [[ "$COVERAGE_SOURCE_ARTIFACT_ID" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::Coverage source artifact ID is missing or malformed for current workflow attempt." + echo "::error::Use a full rerun or a fresh repository dispatch so the producer publishes current-attempt evidence." + exit 1 + fi + artifact_id=$COVERAGE_SOURCE_ARTIFACT_ID + printf 'artifact_id=%s\n' "$artifact_id" >>"$GITHUB_OUTPUT" + + - name: Download current-attempt materialized pull request merge tree + if: >- + always() + && needs.coverage-source-tree.result == 'success' + && steps.coverage_source_identity.outcome == 'success' + id: coverage_source_download + continue-on-error: true uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: opencode-coverage-source + artifact-ids: ${{ steps.coverage_source_identity.outputs.artifact_id }} path: ${{ runner.temp }}/opencode-coverage-artifact + - name: Report missing current-attempt coverage source + if: always() && (needs.coverage-source-tree.result != 'success' || steps.coverage_source_identity.outcome != 'success' || steps.coverage_source_download.outcome != 'success') + env: + GITHUB_RUN_ATTEMPT: ${{ github.run_attempt }} + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + echo "::error::Coverage source evidence is unavailable for workflow run attempt ${GITHUB_RUN_ATTEMPT}; a failed-jobs-only rerun cannot safely reconstruct or reuse source evidence from another attempt." + echo "::error::Use a full rerun or a fresh repository dispatch so coverage-source-tree uploads exact current-attempt evidence." + exit 1 + - name: Prepare pull request merge tree for coverage measurement env: COVERAGE_SOURCE_ARCHIVE: ${{ runner.temp }}/opencode-coverage-artifact/opencode-coverage-source.tar @@ -2507,12 +2564,15 @@ jobs: ContextualWisdomLab/.github:scripts/ci/pr_head_replay_guard.py | \ ContextualWisdomLab/.github:scripts/ci/pr_review_merge_scheduler.py | \ ContextualWisdomLab/.github:scripts/ci/run_opencode_review_model_pool.sh | \ + ContextualWisdomLab/.github:scripts/ci/adversarial_evidence.py | \ ContextualWisdomLab/.github:scripts/ci/opencode_review_normalize_output.py | \ ContextualWisdomLab/.github:scripts/ci/strix_quick_gate.sh | \ ContextualWisdomLab/.github:scripts/ci/validate_opencode_failed_check_review.sh | \ ContextualWisdomLab/.github:tests/test_changed_file_syntax_gate.py | \ ContextualWisdomLab/.github:tests/test_javascript_coverage_gate.py | \ ContextualWisdomLab/.github:tests/test_materialize_base_javascript_packages.py | \ + ContextualWisdomLab/.github:tests/test_adversarial_evidence.py | \ + ContextualWisdomLab/.github:tests/test_opencode_coverage_artifact_rerun_contract.py | \ ContextualWisdomLab/.github:tests/test_opencode_agent_contract.py | \ ContextualWisdomLab/.github:tests/test_opencode_model_pool_runner.py | \ ContextualWisdomLab/.github:tests/test_pr_head_replay_guard.py | \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1630c32d4..af492f628 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,15 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Synchronized the Strix quick-gate contract with the current-attempt OpenCode + coverage-artifact download step, keeping the self-test aligned with the + exact-head artifact provenance boundary. +- Bound OpenCode coverage source evidence to the current workflow attempt and + immutable upload artifact ID, failing closed with full-rerun guidance when a + failed-jobs-only rerun cannot obtain exact current-attempt evidence. +- Kept the extracted adversarial-evidence gate and its contract test inside the + central OpenCode review-process fallback scope, so changes to that trust + boundary retain the same exact-file eligibility and adversarial review path. - Publish only the sanitized cumulative Strix report tree, avoiding a later copy of relative scanner output that could reintroduce known internal warning text into uploaded security evidence. diff --git a/docs/doctoring/opencode-adversarial-fallback-scope.md b/docs/doctoring/opencode-adversarial-fallback-scope.md new file mode 100644 index 000000000..4689886ce --- /dev/null +++ b/docs/doctoring/opencode-adversarial-fallback-scope.md @@ -0,0 +1,44 @@ +# OpenCode adversarial fallback scope + +## Incident boundary + +The central OpenCode fallback allowlist omitted `adversarial_evidence.py` and +its contract test after the gate was extracted from an already-allowlisted +review helper. A pull request that changed the extracted trust-boundary code +therefore lost the bounded central review-process fallback solely because of +the refactor. + +## Decision + +Treat the extracted gate and its test as the same review-process unit as the +normalizer and approval gate by adding their exact repository paths to +`fallback_changed_file_allowed`. The existing +`fallback_changed_file_counts_as_core` function already classifies every +allowlisted central path except `.jules/bolt.md` as core, so no new classifier, +provider rule, credential, or approval path is needed. + +This keeps the changed control and its regression evidence inside one +assessment scope. NIST SP 800-53 Rev. 5, control SA-11, requires ongoing unit, +integration, system, or regression evaluation and evidence at the defined +depth and coverage (National Institute of Standards and Technology, 2020). +The stable OWASP Web Security Testing Guide likewise includes positive and +negative security-control requirements in the security test suite (OWASP +Foundation, 2020). + +## Verification + +`tests/test_opencode_agent_contract.py` pins both exact paths in the workflow. +The allowlist stays closed: unrelated files remain ineligible, and every merge +still requires the existing exact-head checks and independent review policy. +The Strix quick-gate self-test names the current-attempt coverage artifact +download step, so a coverage-artifact hardening rename cannot silently leave +the protected workflow contract stale. + +## References + +National Institute of Standards and Technology. (2020). *Security and privacy +controls for information systems and organizations* (NIST Special Publication +800-53, Revision 5). https://doi.org/10.6028/NIST.SP.800-53r5 + +OWASP Foundation. (2020). *OWASP web security testing guide* (Version 4.2). +https://owasp.org/www-project-web-security-testing-guide/v42/ diff --git a/docs/doctoring/opencode-coverage-artifact-reruns.md b/docs/doctoring/opencode-coverage-artifact-reruns.md new file mode 100644 index 000000000..da7814dda --- /dev/null +++ b/docs/doctoring/opencode-coverage-artifact-reruns.md @@ -0,0 +1,44 @@ +# OpenCode coverage artifact reruns + +## Customer-operability decision + +OpenCode coverage now accepts only the immutable artifact identifier emitted by +`actions/upload-artifact` in the current GitHub Actions workflow attempt. The +producer exports both that identifier and `github.run_attempt`; the +credential-free coverage consumer checks that the producer attempt equals its +own attempt before downloading by ID. + +This closes an evidence-integrity gap in failed-jobs-only reruns. GitHub can +reuse successful job outputs from the earlier attempt, while artifacts can be +expired or unavailable independently. A static artifact name therefore cannot +prove that coverage inspected the exact source produced for the current +attempt. Missing, malformed, expired, or prior-attempt evidence now fails +closed and tells an operator to use a full rerun or a fresh repository +dispatch. The existing one-day retention window remains bounded; no lookup or +fallback to an earlier attempt is permitted. + +## Verification and rollback + +Repository tests parse the complete producer and consumer job blocks. They +require the attempt-scoped artifact name, immutable upload output, current +attempt comparison, exact-ID download, credential-free consumer permissions, +and actionable recovery message. The central workflow's ordinary full quality +gate exercises the new contract at 100% statement, branch, and docstring +coverage. + +Rollback is a normal revert of the workflow, contract test, and fallback-scope +entry. Operators must not restore name-based or prior-attempt artifact lookup; +until a replacement contract is available, a missing producer must continue to +fail closed. + +## References + +GitHub. (n.d.). *Re-running workflows and jobs*. Retrieved August 24, 2026, +from https://docs.github.com/en/actions/how-tos/manage-workflow-runs/re-run-workflows-and-jobs + +GitHub. (n.d.). *REST API endpoints for GitHub Actions artifacts*. Retrieved +August 24, 2026, from +https://docs.github.com/en/rest/actions/artifacts?apiVersion=2026-03-10 + +GitHub. (n.d.). *upload-artifact* [Computer software]. Retrieved August 24, +2026, from https://github.com/actions/upload-artifact/blob/main/README.md diff --git a/scripts/ci/strix_quick_gate.sh b/scripts/ci/strix_quick_gate.sh index 36ec3e5f8..134a831ed 100755 --- a/scripts/ci/strix_quick_gate.sh +++ b/scripts/ci/strix_quick_gate.sh @@ -3119,6 +3119,10 @@ is_timeout_error() { return 0 fi + if grep -Fq 'litellm.Timeout' "$STRIX_LOG"; then + return 0 + fi + if grep -Fq 'Strix run timed out after' "$STRIX_LOG"; then return 0 fi diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 945eb3fb3..75c10d941 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -929,7 +929,7 @@ assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" ' assert_file_contains "$workflow_file" "Materialize pull request merge tree for coverage measurement" "required OpenCode reviews measure coverage instead of approving skipped coverage evidence" assert_file_contains "$workflow_file" "Exchange OpenCode app token for target repository coverage reads" "coverage source materialization can read private target repositories during central manual dispatch" assert_file_contains "$workflow_file" "Upload materialized pull request merge tree" "coverage source materialization passes only a prepared merge tree artifact to the PR-head coverage job" - assert_file_contains "$workflow_file" "Download materialized pull request merge tree" "coverage evidence consumes the prepared merge tree artifact without target-repository credentials" + assert_file_contains "$workflow_file" "Download current-attempt materialized pull request merge tree" "coverage evidence consumes the prepared current-attempt merge tree artifact without target-repository credentials" assert_file_contains "$workflow_file" "Report coverage source materialization failure" "coverage evidence logs source materialization failures as the coverage blocker" local coverage_merge_tree_step coverage_merge_tree_step="$( diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 8ee6e86fc..5a7b7fddd 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1551,6 +1551,10 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): "ContextualWisdomLab/.github:scripts/ci/opencode_review_approve_gate.sh | \\" in workflow ) + assert ( + "ContextualWisdomLab/.github:scripts/ci/adversarial_evidence.py | \\" + in workflow + ) assert "scripts/ci/run_opencode_review_model_pool.sh | \\" in workflow assert ( "ContextualWisdomLab/.github:tests/test_javascript_coverage_gate.py | \\" @@ -1560,6 +1564,14 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): "ContextualWisdomLab/.github:tests/test_materialize_base_javascript_packages.py | \\" in workflow ) + assert ( + "ContextualWisdomLab/.github:tests/test_adversarial_evidence.py | \\" + in workflow + ) + assert ( + "ContextualWisdomLab/.github:tests/test_opencode_coverage_artifact_rerun_contract.py | \\" + in workflow + ) assert "tests/test_opencode_agent_contract.py | \\" in workflow assert ( "ContextualWisdomLab/appguardrail:scripts/ci/collect_org_security_failures.py" diff --git a/tests/test_opencode_coverage_artifact_rerun_contract.py b/tests/test_opencode_coverage_artifact_rerun_contract.py new file mode 100644 index 000000000..7a891ac36 --- /dev/null +++ b/tests/test_opencode_coverage_artifact_rerun_contract.py @@ -0,0 +1,187 @@ +"""Contracts for rerun-safe OpenCode coverage artifact handoff.""" + +from pathlib import Path + + +WORKFLOW_PATH = Path(".github/workflows/opencode-review-dispatch.yml") +TEMPORARY_REPAIR_GLOBS = ( + ".github/opencode-attempt-scoped-coverage-artifact*.trigger", + ".github/pr812*.trigger", + ".github/workflows/*opencode*artifact*materializ*.yml", + ".github/workflows/*opencode*artifact*repair*.yml", + ".github/workflows/pr812-finalize*.yml", + "scripts/ci/*opencode*artifact*patch*.py", +) + + +def _workflow_text() -> str: + """Return the protected OpenCode repository-dispatch workflow source.""" + return WORKFLOW_PATH.read_text(encoding="utf-8") + + +def _job_block(workflow: str, job_name: str, next_job_name: str) -> str: + """Return one top-level workflow job block bounded by the next job.""" + start = workflow.index(f" {job_name}:\n") + end = workflow.index(f"\n {next_job_name}:\n", start) + return workflow[start:end] + + +def _step_block(job: str, step_name: str, next_step_name: str) -> str: + """Return one workflow step bounded by the following named step.""" + start = job.index(f" - name: {step_name}\n") + end = job.index(f"\n - name: {next_step_name}\n", start) + return job[start:end] + + +def test_coverage_source_artifact_is_attempt_scoped_and_downloaded_by_id() -> None: + """Bind every producer attempt to its immutable uploaded artifact ID.""" + workflow = _workflow_text() + source_job = _job_block(workflow, "coverage-source-tree", "coverage-evidence") + evidence_job = _job_block(workflow, "coverage-evidence", "opencode-review-target") + + assert ( + "coverage_source_artifact_id: " + "${{ steps.coverage_source_upload.outputs.artifact-id }}" + in source_job + ) + assert "id: coverage_source_upload" in source_job + assert "name: opencode-coverage-source-${{ github.run_attempt }}" in source_job + assert "retention-days: 1" in source_job + + identity = _step_block( + evidence_job, + "Verify coverage source identity for current workflow attempt", + "Download current-attempt materialized pull request merge tree", + ) + download = _step_block( + evidence_job, + "Download current-attempt materialized pull request merge tree", + "Report missing current-attempt coverage source", + ) + assert "id: coverage_source_identity" in identity + assert ( + "COVERAGE_SOURCE_ARTIFACT_ID: " + "${{ needs.coverage-source-tree.outputs.coverage_source_artifact_id }}" + in identity + ) + assert '[[ "$COVERAGE_SOURCE_ARTIFACT_ID" =~ ^[1-9][0-9]*$ ]]' in identity + assert "artifact_id=$COVERAGE_SOURCE_ARTIFACT_ID" in identity + assert ( + "artifact-ids: ${{ steps.coverage_source_identity.outputs.artifact_id }}" + in download + ) + assert ( + "artifact-ids: " + "${{ needs.coverage-source-tree.outputs.coverage_source_artifact_id }}" + not in download + ) + assert "name: opencode-coverage-source\n" not in download + + +def test_coverage_source_requires_current_producer_attempt() -> None: + """Reject reused producer output when a selective rerun advances the attempt.""" + workflow = _workflow_text() + source_job = _job_block(workflow, "coverage-source-tree", "coverage-evidence") + evidence_job = _job_block(workflow, "coverage-evidence", "opencode-review-target") + identity = _step_block( + evidence_job, + "Verify coverage source identity for current workflow attempt", + "Download current-attempt materialized pull request merge tree", + ) + + assert ( + "coverage_source_run_attempt: " + "${{ steps.coverage_source_attempt.outputs.run_attempt }}" + in source_job + ) + assert "id: coverage_source_attempt" in source_job + assert "GITHUB_RUN_ATTEMPT: ${{ github.run_attempt }}" in source_job + assert "run_attempt=%s" in source_job + + assert ( + "COVERAGE_SOURCE_RUN_ATTEMPT: " + "${{ needs.coverage-source-tree.outputs.coverage_source_run_attempt }}" + in identity + ) + assert "CURRENT_RUN_ATTEMPT: ${{ github.run_attempt }}" in identity + assert '[ "$COVERAGE_SOURCE_RUN_ATTEMPT" != "$CURRENT_RUN_ATTEMPT" ]' in identity + assert "failed-jobs-only reruns cannot reuse prior-attempt source evidence" in identity + assert "full rerun or a fresh repository dispatch" in identity + + guard_index = evidence_job.index( + "- name: Verify coverage source identity for current workflow attempt" + ) + download_index = evidence_job.index( + "- name: Download current-attempt materialized pull request merge tree" + ) + assert guard_index < download_index + + +def test_missing_or_expired_artifact_fails_with_bounded_recovery_guidance() -> None: + """Keep fail-closed recovery reachable after producer or download failures.""" + workflow = _workflow_text() + evidence_job = _job_block(workflow, "coverage-evidence", "opencode-review-target") + producer_failure = _step_block( + evidence_job, + "Report coverage source materialization failure", + "Verify coverage source identity for current workflow attempt", + ) + identity = _step_block( + evidence_job, + "Verify coverage source identity for current workflow attempt", + "Download current-attempt materialized pull request merge tree", + ) + download = _step_block( + evidence_job, + "Download current-attempt materialized pull request merge tree", + "Report missing current-attempt coverage source", + ) + recovery = _step_block( + evidence_job, + "Report missing current-attempt coverage source", + "Prepare pull request merge tree for coverage measurement", + ) + + assert "if: needs.coverage-source-tree.result != 'success'" in producer_failure + assert "exit 1" not in producer_failure + assert "id: coverage_source_identity" in identity + assert "if: always()" in identity + assert "continue-on-error: true" in identity + assert "id: coverage_source_download" in download + assert "continue-on-error: true" in download + assert "needs.coverage-source-tree.result == 'success'" in download + assert "steps.coverage_source_identity.outcome == 'success'" in download + assert "if: always() && (" in recovery + assert "needs.coverage-source-tree.result != 'success'" in recovery + assert "steps.coverage_source_identity.outcome != 'success'" in recovery + assert "steps.coverage_source_download.outcome != 'success'" in recovery + assert "failed-jobs-only rerun" in recovery + assert "full rerun or a fresh repository dispatch" in recovery + assert "GITHUB_RUN_ATTEMPT" in recovery + assert "exit 1" in recovery + assert "list-artifacts" not in identity + download + recovery + + +def test_coverage_consumer_remains_credential_free() -> None: + """Keep repository and OIDC credentials outside the untrusted-test job.""" + workflow = _workflow_text() + evidence_job = _job_block(workflow, "coverage-evidence", "opencode-review-target") + permissions = evidence_job.split(" outputs:\n", 1)[0] + + assert "actions: read" in permissions + assert "contents:" not in permissions + assert "id-token:" not in permissions + assert "secrets." not in evidence_job + assert "GH_TOKEN:" not in evidence_job + + +def test_temporary_branch_writers_are_absent_from_final_tree() -> None: + """Reject versioned or renamed materializers and branch finalizers.""" + unexpected = sorted( + { + str(path) + for pattern in TEMPORARY_REPAIR_GLOBS + for path in Path(".").glob(pattern) + } + ) + assert unexpected == [] diff --git a/tests/test_pr_review_autofix_nvidia_nim_contract.py b/tests/test_pr_review_autofix_nvidia_nim_contract.py index 799b9e9fb..e1a45cc86 100644 --- a/tests/test_pr_review_autofix_nvidia_nim_contract.py +++ b/tests/test_pr_review_autofix_nvidia_nim_contract.py @@ -19,7 +19,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 = "dd65d90e10e5040562b501ade1a40f89572f0984" +REVIEW_DISPATCH_BLOB_SHA = "6c4c50b11c06afba4f997ef5f9354ab4824b317b" def _workflow_text(path: Path) -> str: