diff --git a/.github/workflows/strix.yml b/.github/workflows/strix.yml index 8b43ce615..10c74528b 100644 --- a/.github/workflows/strix.yml +++ b/.github/workflows/strix.yml @@ -83,6 +83,10 @@ on: type: string concurrency: + # Manual evidence runs are workflow_dispatch-scoped by target repository: + # github.event_name == 'workflow_dispatch' && github.event.inputs.target_repository + # Pull request evidence runs are scoped by base repository: + # github.event_name == 'pull_request_target' && github.event.pull_request.base.repo.full_name group: >- strix-${{ github.event.inputs.target_repository || github.event.pull_request.base.repo.full_name || github.repository }}-${{ github.event_name == 'pull_request_target' && format('pr-{0}-{1}', github.event.pull_request.number, github.event.pull_request.head.sha) || @@ -120,14 +124,14 @@ jobs: timeout-minutes: 45 runs-on: ubuntu-latest # Least-privilege token scoped to this job (Scorecard alert #43): the scan - # exchanges an OIDC token (id-token) and posts a commit status - # (statuses:write); all other scopes stay read-only. + # exchanges an OIDC token (id-token); commit-status publication uses + # explicit app/secret tokens, so GITHUB_TOKEN statuses stays read-only. permissions: actions: read contents: read id-token: write models: read - statuses: write + statuses: read env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: @@ -727,7 +731,6 @@ jobs: TARGET_APP_STATUS_TOKEN: ${{ steps.target_app_token.outputs.token || '' }} PR_REVIEW_MERGE_STATUS_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || '' }} OPENCODE_APPROVE_STATUS_TOKEN: ${{ secrets.OPENCODE_APPROVE_TOKEN || '' }} - GITHUB_STATUS_TOKEN: ${{ github.token }} TARGET_REPOSITORY: ${{ github.event.inputs.target_repository || github.repository }} PR_HEAD_SHA: ${{ github.event.inputs.pr_head_sha }} STRIX_RESULT: ${{ job.status }} @@ -790,9 +793,6 @@ jobs: if post_strix_status "opencode-approve-token" "$OPENCODE_APPROVE_STATUS_TOKEN"; then exit 0 fi - if post_strix_status "github-token" "$GITHUB_STATUS_TOKEN"; then - exit 0 - fi echo "::warning::Could not publish manual Strix status from scan job; keeping scan evidence result authoritative in the workflow run." publish-manual-pr-evidence-status: diff --git a/scripts/ci/emit_opencode_failed_check_fallback_findings.sh b/scripts/ci/emit_opencode_failed_check_fallback_findings.sh index 61b8c6d67..ea14c56ba 100755 --- a/scripts/ci/emit_opencode_failed_check_fallback_findings.sh +++ b/scripts/ci/emit_opencode_failed_check_fallback_findings.sh @@ -368,6 +368,50 @@ emit_known_missing_string_finding() { fi } +emit_known_unexpected_string_finding() { + local evidence_file="$1" + local needle="$2" + local title="$3" + local preferred_path + local match="" + local path="" + local line="" + + if ! grep -Fq -- "unexpected '$needle'" "$evidence_file" && + ! grep -Fq -- "unexpected \"$needle\"" "$evidence_file"; then + return 0 + fi + + shift 3 + for preferred_path in "$@"; do + if [ -f "${REPO_ROOT%/}/$preferred_path" ]; then + match="$(grep -nF -- "$needle" "${REPO_ROOT%/}/$preferred_path" | head -n 1 || true)" + if [ -n "$match" ]; then + path="$preferred_path" + line="${match%%:*}" + break + fi + fi + done + + finding_index=$((finding_index + 1)) + if [ -n "$path" ] && [ -n "$line" ]; then + printf '### %s. HIGH %s:%s - %s\n' "$finding_index" "$path" "$line" "$title" + printf -- '- Problem: Strix failed because the trusted self-test log reported forbidden "%s" in the required workflow.\n' "$needle" + printf -- '- Root cause: The required workflow grants a broader GITHUB_TOKEN permission than the smoke-test contract allows; required PR scans must keep status publication on explicit app/secret tokens.\n' + printf -- '- Fix: Remove or downgrade `%s` at `%s:%s` so the required workflow keeps GITHUB_TOKEN status permissions read-only.\n' "$needle" "$path" "$line" + printf -- '- Regression test: Keep scripts/ci/strix_required_workflow_smoke.sh and scripts/ci/test_strix_quick_gate.sh asserting that the required Strix workflow does not contain `%s`.\n\n' "$needle" + printf -- '- Suggested edit: change `%s:%s` from `%s` to `statuses: read`, or remove the permission if no status read is needed.\n\n' "$path" "$line" "$needle" + else + printf '### %s. HIGH unknown:1 - %s\n' "$finding_index" "$title" + printf -- '- Problem: Strix failed because the trusted self-test log reported forbidden "%s", but the current source no longer contains that literal in the expected files.\n' "$needle" + printf -- '- Root cause: The failed check likely used stale trusted-base workflow material or the evidence did not include a mappable current-head source line.\n' + printf -- '- Fix: Rerun the current-head Strix check after confirming the workflow and tests no longer contain `%s`.\n' "$needle" + printf -- '- Regression test: Keep the required workflow smoke test covering this forbidden literal.\n\n' + printf -- '- Suggested edit: no source edit can be suggested from the current source; rerun after the trusted workflow source updates.\n\n' + fi +} + all_failed_check_blocks_have_billing_lock() { local evidence_file="$1" @@ -925,6 +969,13 @@ emit_known_missing_string_finding \ "OpenCode review must try GitHub Models GPT-5 first" \ ".github/workflows/opencode-review.yml" \ "scripts/ci/test_strix_quick_gate.sh" +emit_known_unexpected_string_finding \ + "$EVIDENCE_FILE" \ + "statuses: write" \ + "Strix required workflow must keep GITHUB_TOKEN statuses read-only" \ + ".github/workflows/strix.yml" \ + "scripts/ci/test_strix_quick_gate.sh" \ + "scripts/ci/strix_required_workflow_smoke.sh" emit_github_billing_lock_finding emit_pytest_failure_findings "$EVIDENCE_FILE" diff --git a/scripts/ci/opencode_review_approve_gate.sh b/scripts/ci/opencode_review_approve_gate.sh index ca033b115..392343397 100755 --- a/scripts/ci/opencode_review_approve_gate.sh +++ b/scripts/ci/opencode_review_approve_gate.sh @@ -71,18 +71,106 @@ if [ -z "$CONTROL_JSON" ]; then fi TMP_JSON="$(mktemp)" -trap 'rm -f "$TMP_JSON"' EXIT +TMP_FIELDS="$(mktemp)" +trap 'rm -f "$TMP_JSON" "$TMP_FIELDS"' EXIT printf '%s\n' "$CONTROL_JSON" >"$TMP_JSON" -if ! jq -e . "$TMP_JSON" >/dev/null 2>&1; then +if ! python3 - "$TMP_JSON" >"$TMP_FIELDS" <<'PY' +from __future__ import annotations + +import json +import math +import sys +from pathlib import Path + + +def fail() -> None: + raise SystemExit(1) + + +def nonempty_string(value: object) -> bool: + return isinstance(value, str) and len(value) > 0 + + +def valid_finding(value: object) -> bool: + if not isinstance(value, dict): + return False + path = value.get("path") + if not nonempty_string(path): + return False + if str(path).casefold() in {"n/a", "unknown"}: + return False + line = value.get("line") + if ( + isinstance(line, bool) + or not isinstance(line, (int, float)) + or not math.isfinite(float(line)) + or line <= 0 + or math.floor(float(line)) != float(line) + ): + return False + required_strings = ( + "severity", + "title", + "problem", + "root_cause", + "fix_direction", + "regression_test_direction", + "suggested_diff", + ) + if not all(nonempty_string(value.get(field)) for field in required_strings): + return False + suggested_diff = str(value.get("suggested_diff", "")).casefold() + if suggested_diff.startswith("n/a") or suggested_diff.startswith("cannot provide diff"): + return False + return True + + +try: + control = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +except (OSError, json.JSONDecodeError): + fail() + +if not isinstance(control, dict): + fail() + +if not all(nonempty_string(control.get(field)) for field in ("head_sha", "run_id", "run_attempt", "reason", "summary")): + fail() + +result = control.get("result") +if result not in {"APPROVE", "REQUEST_CHANGES"}: + fail() + +findings = control.get("findings") +if result == "REQUEST_CHANGES": + if not isinstance(findings, list) or len(findings) == 0: + fail() +elif findings is not None and (not isinstance(findings, list) or len(findings) != 0): + fail() + +if not all(valid_finding(finding) for finding in (findings or [])): + fail() + +print(control["head_sha"]) +print(control["run_id"]) +print(control["run_attempt"]) +print(result) +PY +then + echo "NO_CONCLUSION" + exit 4 +fi + +mapfile -t CONTROL_FIELDS <"$TMP_FIELDS" +if [ "${#CONTROL_FIELDS[@]}" -ne 4 ]; then echo "NO_CONCLUSION" exit 4 fi -CONTROL_HEAD_SHA="$(jq -r '.head_sha // empty' "$TMP_JSON")" -CONTROL_RUN_ID="$(jq -r '.run_id // empty' "$TMP_JSON")" -CONTROL_RUN_ATTEMPT="$(jq -r '.run_attempt // empty' "$TMP_JSON")" -RESULT="$(jq -r '.result // empty' "$TMP_JSON")" +CONTROL_HEAD_SHA="${CONTROL_FIELDS[0]%$'\r'}" +CONTROL_RUN_ID="${CONTROL_FIELDS[1]%$'\r'}" +CONTROL_RUN_ATTEMPT="${CONTROL_FIELDS[2]%$'\r'}" +RESULT="${CONTROL_FIELDS[3]%$'\r'}" if [ "$CONTROL_HEAD_SHA" != "$EXPECTED_HEAD_SHA" ]; then echo "SHA_MISMATCH" @@ -99,37 +187,6 @@ if [ "$EXPECTED_RUN_ATTEMPT" != "-" ] && [ "$CONTROL_RUN_ATTEMPT" != "$EXPECTED_ exit 2 fi -if ! jq -e ' - type == "object" - and (.head_sha | type == "string" and length > 0) - and (.run_id | type == "string" and length > 0) - and (.run_attempt | type == "string" and length > 0) - and (.result == "APPROVE" or .result == "REQUEST_CHANGES") - and (.reason | type == "string" and length > 0) - and (.summary | type == "string" and length > 0) - and ( - if .result == "REQUEST_CHANGES" then (.findings | type == "array" and length > 0) - else ((.findings == null) or (.findings | type == "array" and length == 0)) - end - ) - and all((.findings // [])[]; - (.path | type == "string" and length > 0) - and ((.path | ascii_downcase) as $p | ($p != "n/a" and $p != "unknown")) - and (.line | type == "number" and . > 0 and floor == .) - and (.severity | type == "string" and length > 0) - and (.title | type == "string" and length > 0) - and (.problem | type == "string" and length > 0) - and (.root_cause | type == "string" and length > 0) - and (.fix_direction | type == "string" and length > 0) - and (.regression_test_direction | type == "string" and length > 0) - and (.suggested_diff | type == "string" and length > 0) - and ((.suggested_diff | ascii_downcase) as $d | (($d | startswith("n/a")) | not) and (($d | startswith("cannot provide diff")) | not)) - ) -' "$TMP_JSON" >/dev/null; then - echo "NO_CONCLUSION" - exit 4 -fi - if ! python3 "$NORMALIZER" --check-structural-approval "$TMP_JSON" >/dev/null; then echo "NO_CONCLUSION" exit 4 @@ -285,7 +342,33 @@ then fi if [ -n "$NORMALIZED_JSON_FILE" ]; then - jq -c '{head_sha, run_id, run_attempt, result, reason, summary, findings:(.findings // [])}' "$TMP_JSON" >"$NORMALIZED_JSON_FILE" + if ! python3 - "$TMP_JSON" "$NORMALIZED_JSON_FILE" <<'PY' +from __future__ import annotations + +import json +import sys +from pathlib import Path + + +control = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +normalized = { + "head_sha": control["head_sha"], + "run_id": control["run_id"], + "run_attempt": control["run_attempt"], + "result": control["result"], + "reason": control["reason"], + "summary": control["summary"], + "findings": control.get("findings") or [], +} +Path(sys.argv[2]).write_text( + json.dumps(normalized, separators=(",", ":")) + "\n", + encoding="utf-8", +) +PY + then + echo "NO_CONCLUSION" + exit 4 + fi fi echo "$RESULT" diff --git a/scripts/ci/strix_required_workflow_smoke.sh b/scripts/ci/strix_required_workflow_smoke.sh index a9c8130a1..84a4a5afb 100755 --- a/scripts/ci/strix_required_workflow_smoke.sh +++ b/scripts/ci/strix_required_workflow_smoke.sh @@ -79,6 +79,7 @@ if any(line.strip() == "statuses: write" for line in top_level_permissions): print("Strix workflow top-level GITHUB_TOKEN must not grant statuses: write.", file=sys.stderr) raise SystemExit(1) +status_read_jobs: list[str] = [] status_write_jobs: list[str] = [] current_job = "" inside_permissions = False @@ -94,16 +95,25 @@ for line in lines[jobs_index + 1 :]: if not inside_permissions: continue if line.startswith(" "): + if line.strip() == "statuses: read": + status_read_jobs.append(current_job) if line.strip() == "statuses: write": status_write_jobs.append(current_job) continue if line.strip(): inside_permissions = False -if status_write_jobs != ["strix"]: +if status_write_jobs: print( - "Strix workflow must scope statuses: write only to the strix scan job; found: " - + (", ".join(status_write_jobs) if status_write_jobs else "none"), + "Strix workflow GITHUB_TOKEN status permissions must stay read-only; found statuses: write in: " + + ", ".join(status_write_jobs), + file=sys.stderr, + ) + raise SystemExit(1) + +if "strix" not in status_read_jobs: + print( + "Strix workflow scan job must retain statuses: read for existing status evidence.", file=sys.stderr, ) raise SystemExit(1) diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 76e31eddf..1f81a8713 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -789,14 +789,16 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$workflow_file" 'Manual workflow_dispatch Strix evidence passed' "opencode approval requires an explicit manual Strix evidence status description" assert_file_contains "$workflow_file" 'last // empty' "opencode approval checks the latest strix status before accepting manual success evidence" assert_file_contains "$REPO_ROOT/.github/workflows/strix.yml" 'publish-manual-pr-evidence-status:' "strix workflow publishes same-head manual PR evidence as a commit status" - assert_file_contains "$REPO_ROOT/.github/workflows/strix.yml" 'statuses: write' "strix manual evidence status job has commit-status write permission" + assert_file_not_contains "$REPO_ROOT/.github/workflows/strix.yml" 'statuses: write' "strix GITHUB_TOKEN status permission stays read-only" + assert_file_contains "$REPO_ROOT/.github/workflows/strix.yml" 'statuses: read' "strix GITHUB_TOKEN can read existing status evidence" assert_file_contains "$REPO_ROOT/.github/workflows/strix.yml" 'TARGET_REPOSITORY: ${{ github.event.inputs.target_repository || github.repository }}' "strix manual evidence status publishes to the requested target repository" assert_file_contains "$REPO_ROOT/.github/workflows/strix.yml" 'context="strix"' "strix manual evidence status uses the status context consumed by OpenCode" assert_file_contains "$REPO_ROOT/.github/workflows/strix.yml" 'repos/${TARGET_REPOSITORY}/statuses/${PR_HEAD_SHA}' "strix manual evidence status does not post private-target evidence to .github by mistake" assert_file_contains "$REPO_ROOT/.github/workflows/strix.yml" 'PR_REVIEW_MERGE_STATUS_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || '"'"''"'"' }}' "strix manual evidence status can publish cross-repo evidence with the central mutation credential" assert_file_contains "$REPO_ROOT/.github/workflows/strix.yml" 'post_strix_status "pr-review-merge-token" "$PR_REVIEW_MERGE_STATUS_TOKEN"' "strix manual evidence status retries the central mutation credential when the target app token cannot write statuses" assert_file_contains "$REPO_ROOT/.github/workflows/strix.yml" 'post_strix_status "opencode-approve-token" "$OPENCODE_APPROVE_STATUS_TOKEN"' "strix manual evidence status retries the approval credential before declaring status publication unavailable" - assert_file_contains "$REPO_ROOT/.github/workflows/strix.yml" 'post_strix_status "github-token" "$GITHUB_STATUS_TOKEN"' "strix manual evidence status keeps same-repository github-token fallback" + assert_file_not_contains "$REPO_ROOT/.github/workflows/strix.yml" 'post_strix_status "github-token" "$GITHUB_STATUS_TOKEN"' "strix manual evidence status does not reintroduce a status-writing GITHUB_TOKEN fallback" + assert_file_contains "$REPO_ROOT/.github/workflows/strix.yml" 'post_strix_status "target-app-token" "$TARGET_APP_STATUS_TOKEN"' "strix manual evidence status uses the target app token first" assert_file_contains "$REPO_ROOT/.github/workflows/strix.yml" 'Manual workflow_dispatch Strix evidence failed' "strix manual evidence status records failed reruns so older success cannot mask newer failure" assert_file_contains "$REPO_ROOT/.github/workflows/strix.yml" 'Could not publish manual Strix status from scan job' "strix scan evidence does not fail solely because target status publication is unavailable" assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" '"workflow_run"' "failed-check evidence includes failed same-head workflow runs outside statusCheckRollup" @@ -813,7 +815,7 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" '((.name // "") | contains("${{"))' "failed-check evidence ignores cancelled matrix-template helper checks without logs" assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" '(.name // "") == "noema-review"' "failed-check evidence ignores cancelled Noema queue replacement checks without source logs" assert_file_contains "$workflow_file" 'metadata-only gate evaluation' "opencode approval gate ignores cancelled metadata-only PR Governance helper gates" - assert_file_contains "$workflow_file" '((.name // "") | contains("$" + "{{"))' "opencode failed-check collection ignores cancelled matrix-template helper checks without logs without exposing a workflow-expression token" + assert_file_contains "$workflow_file" '((.name // "") | contains("$" + "{{"))' "opencode failed-check collection ignores cancelled matrix-template helper checks without logs without exposing a raw Actions expression" assert_file_contains "$workflow_file" '(.name // "") == "noema-review"' "opencode failed-check collection ignores cancelled Noema queue replacement checks without source logs" assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" '"strix security scan/"*' "failed-check evidence maps stale Strix workflow helper checks to the manual strix evidence status" assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" '$successful_strix_runs > 0' "failed-check evidence drops cancelled duplicate Strix runs once same-head Strix evidence succeeded" @@ -959,9 +961,10 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$workflow_file" "Failed-check findings must be line-specific and concrete" "opencode review prompt requires line-specific failed-check findings" assert_file_contains "$workflow_file" "never use line 0" "opencode review prompt forbids non-specific line 0 findings" assert_file_contains "$workflow_file" "The suggested_diff must be source-backed and GitHub suggestion-ready when possible: every removed line in the diff must exist in the cited current local file" "opencode review prompt forbids non-source-backed suggested diffs" - assert_file_contains "$REPO_ROOT/scripts/ci/opencode_review_approve_gate.sh" '.line | type == "number" and . > 0 and floor == .' "opencode approval gate rejects line zero findings" - assert_file_contains "$REPO_ROOT/scripts/ci/opencode_review_approve_gate.sh" '$p != "n/a" and $p != "unknown"' "opencode approval gate rejects placeholder finding paths" + assert_file_contains "$REPO_ROOT/scripts/ci/opencode_review_approve_gate.sh" "math.floor(float(line)) != float(line)" "opencode approval gate rejects line zero findings" + assert_file_contains "$REPO_ROOT/scripts/ci/opencode_review_approve_gate.sh" 'str(path).casefold() in {"n/a", "unknown"}' "opencode approval gate rejects placeholder finding paths" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_review_approve_gate.sh" 'startswith("cannot provide diff")' "opencode approval gate rejects placeholder suggested diffs" + assert_file_not_contains "$REPO_ROOT/scripts/ci/opencode_review_approve_gate.sh" 'jq ' "opencode approval gate does not depend on runner jq availability" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_review_approve_gate.sh" "source_file.is_file()" "opencode approval gate requires finding paths to exist" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_review_approve_gate.sh" "removed_line not in source_line_set" "opencode approval gate rejects suggested diffs that remove code absent from the cited file" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_review_normalize_output.py" "isinstance(line, bool)" "opencode normalizer rejects boolean line findings" @@ -1207,7 +1210,7 @@ EOF cat >"$output_file" <<'EOF' OpenCode transcript text before the review control block. -{"head_sha":"abc123","run_id":"42","run_attempt":"1","result":"APPROVE","reason":"No blockers found after inspecting .github/workflows/opencode-review.yml.","summary":"Approval sufficiency: affirmative evidence supported approval beyond absence of blockers. Reviewed .github/workflows/opencode-review.yml, scripts/ci/opencode_review_normalize_output.py, and scripts/ci/test_strix_quick_gate.sh. Verification posture: Linter/static: actionlint and bash syntax evidence passed. TDD/regression: scripts/ci/test_strix_quick_gate.sh self-test evidence passed. Coverage: Coverage execution evidence reported 100% test coverage. Docstring coverage: Coverage execution evidence reported 100% docstring coverage. DAG: CodeGraph behavior DAG rendered .github/workflows/opencode-review.yml to GitHub Actions review job and verification path. PoC/execution: scratch PoC executed bash scripts/ci/test_strix_quick_gate.sh and passed. DDD/domain: no product domain boundary changed. CDD/context: CodeGraph structural MCP evidence covered the workflow and script blast radius. Similar issues: checked related OpenCode gate cases. Claim/concept check: no unverified user concept accepted. Standards search: checked current GitHub Actions/OpenCode docs where applicable. Compatibility/convention: workflow naming and shell conventions match existing code. Breaking-change/backcompat: no deployed public contract changed. Performance: no runtime path affected. Developer experience: review automation remains clear to maintainers and contributors. User experience: no user-facing UI affected. Visual/DOM: non-web workflow and review-comment output was checked. Accessibility/i18n: human-readable workflow and review text was checked. Supply-chain/license: dependency and external-tool risk was checked. Packaging: package and workflow contracts were checked. Security/privacy: token and pull_request_target boundaries preserved.","findings":[]} +{"head_sha":"abc123","run_id":"42","run_attempt":"1","result":"APPROVE","reason":"No blockers found after structural exploration of .github/workflows/opencode-review.yml.","summary":"Approval sufficiency: affirmative evidence supported approval beyond absence of blockers. Reviewed .github/workflows/opencode-review.yml, scripts/ci/opencode_review_normalize_output.py, and scripts/ci/test_strix_quick_gate.sh. Verification posture: Linter/static: actionlint and bash syntax evidence passed. TDD/regression: scripts/ci/test_strix_quick_gate.sh self-test evidence passed. Coverage: Coverage execution evidence reported 100% test coverage. Docstring coverage: Coverage execution evidence reported 100% docstring coverage. DAG: CodeGraph behavior DAG rendered .github/workflows/opencode-review.yml to GitHub Actions review job and verification path. PoC/execution: scratch PoC executed bash scripts/ci/test_strix_quick_gate.sh and passed. DDD/domain: no product domain boundary changed. CDD/context: CodeGraph structural MCP evidence covered the workflow and script blast radius. Similar issues: checked related OpenCode gate cases. Claim/concept check: no unverified user concept accepted. Standards search: checked current GitHub Actions/OpenCode docs where applicable. Compatibility/convention: workflow naming and shell conventions match existing code. Breaking-change/backcompat: no deployed public contract changed. Performance: no runtime path affected. Developer experience: review automation remains clear to maintainers and contributors. User experience: no user-facing UI affected. Visual/DOM: non-web workflow and review-comment output was checked. Accessibility/i18n: human-readable workflow and review text was checked. Supply-chain/license: dependency and external-tool risk was checked. Packaging: package and workflow contracts were checked. Security/privacy: token and pull_request_target boundaries preserved.","findings":[]} EOF set +e @@ -1262,7 +1265,7 @@ EOF But that is not meticulous. @@ -2647,6 +2650,49 @@ EOF rm -rf "$tmp_dir" } +assert_opencode_failed_check_fallback_maps_strix_status_permission_smoke_failure() { + local tmp_dir + local fixture_repo + local evidence_file + local output_file + tmp_dir="$(mktemp -d)" + fixture_repo="$tmp_dir/repo" + evidence_file="$tmp_dir/failed-check-evidence.md" + output_file="$tmp_dir/fallback.md" + + mkdir -p "$fixture_repo/.github/workflows" "$fixture_repo/scripts/ci" + cat >"$fixture_repo/.github/workflows/strix.yml" <<'EOF' +name: Strix Security Scan +jobs: + strix: + permissions: + contents: read + statuses: write +EOF + + cat >"$evidence_file" <<'EOF' +## Failed check: Strix Security Scan/strix + +### Failed log signal summary + +```text +strix Self-test Strix required workflow contract Running bounded Strix required-workflow smoke test. +strix Self-test Strix required workflow contract FAIL: Strix workflow keeps GITHUB_TOKEN status permissions read-only (unexpected 'statuses: write') +strix Self-test Strix required workflow contract Strix required workflow smoke test failed with 1 failure(s). +``` +EOF + + bash "$REPO_ROOT/scripts/ci/emit_opencode_failed_check_fallback_findings.sh" \ + "$evidence_file" "$fixture_repo" >"$output_file" + + assert_file_contains "$output_file" "Strix required workflow must keep GITHUB_TOKEN statuses read-only" "fallback maps Strix smoke permission failure" + assert_file_contains "$output_file" ".github/workflows/strix.yml:6" "fallback cites the exact statuses write line" + assert_file_contains "$output_file" 'change `.github/workflows/strix.yml:6` from `statuses: write` to `statuses: read`' "fallback gives a concrete status-permission repair" + assert_file_not_contains "$output_file" "No source-backed failed-check fallback finding matched" "fallback does not leave Strix smoke failure undiagnosed" + + rm -rf "$tmp_dir" +} + assert_internal_pr_scope_targets() { local target_log_file="$1" local repo_root_dir="$2" @@ -7736,6 +7782,8 @@ assert_opencode_failed_check_fallback_handles_split_code_location_lines assert_opencode_failed_check_fallback_does_not_anchor_unmapped_strix_reports_to_workflow +assert_opencode_failed_check_fallback_maps_strix_status_permission_smoke_failure + run_pull_request_target_head_scope_case \ "pull-request-target-modified-file-uses-head-blob" \ "src/app.py" \