diff --git a/.github/workflows/strix.yml b/.github/workflows/strix.yml index 10c74528b..a9ae3efbc 100644 --- a/.github/workflows/strix.yml +++ b/.github/workflows/strix.yml @@ -124,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); commit-status publication uses - # explicit app/secret tokens, so GITHUB_TOKEN statuses stays read-only. + # exchanges an OIDC token (id-token) and posts same-repository commit status + # fallback from this job; all other jobs keep commit-status read-only. permissions: actions: read contents: read id-token: write models: read - statuses: read + statuses: write env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: @@ -334,6 +334,11 @@ jobs: if git -C "$TRUSTED_WORKSPACE" cat-file -e "$PR_HEAD_SHA:opencode.jsonc" 2>/dev/null; then git -C "$TRUSTED_WORKSPACE" show "$PR_HEAD_SHA:opencode.jsonc" > "$TRUSTED_WORKSPACE/opencode.jsonc" fi + if git -C "$TRUSTED_WORKSPACE" cat-file -e "$PR_HEAD_SHA:.github/workflows/strix.yml" 2>/dev/null; then + mkdir -p "$TRUSTED_WORKSPACE/.github/workflows" + git -C "$TRUSTED_WORKSPACE" show "$PR_HEAD_SHA:.github/workflows/strix.yml" > "$TRUSTED_WORKSPACE/.github/workflows/strix.yml" + echo "Materialized PR-head Strix workflow for self-test." + fi if git -C "$TRUSTED_WORKSPACE" cat-file -e "$PR_HEAD_SHA:scripts/ci/pr_review_merge_scheduler.py" 2>/dev/null; then mkdir -p "$TRUSTED_WORKSPACE/scripts/ci" git -C "$TRUSTED_WORKSPACE" show "$PR_HEAD_SHA:scripts/ci/pr_review_merge_scheduler.py" > "$TRUSTED_WORKSPACE/scripts/ci/pr_review_merge_scheduler.py" @@ -349,6 +354,11 @@ jobs: if git -C "$TRUSTED_WORKSPACE" cat-file -e "$PR_HEAD_SHA:opencode.jsonc" 2>/dev/null; then git -C "$TRUSTED_WORKSPACE" show "$PR_HEAD_SHA:opencode.jsonc" > "$TRUSTED_WORKSPACE/opencode.jsonc" fi + if git -C "$TRUSTED_WORKSPACE" cat-file -e "$PR_HEAD_SHA:.github/workflows/strix.yml" 2>/dev/null; then + mkdir -p "$TRUSTED_WORKSPACE/.github/workflows" + git -C "$TRUSTED_WORKSPACE" show "$PR_HEAD_SHA:.github/workflows/strix.yml" > "$TRUSTED_WORKSPACE/.github/workflows/strix.yml" + echo "Materialized PR-head Strix workflow for self-test." + fi if git -C "$TRUSTED_WORKSPACE" cat-file -e "$PR_HEAD_SHA:scripts/ci/pr_review_merge_scheduler.py" 2>/dev/null; then mkdir -p "$TRUSTED_WORKSPACE/scripts/ci" git -C "$TRUSTED_WORKSPACE" show "$PR_HEAD_SHA:scripts/ci/pr_review_merge_scheduler.py" > "$TRUSTED_WORKSPACE/scripts/ci/pr_review_merge_scheduler.py" @@ -729,6 +739,7 @@ jobs: if: ${{ always() && !cancelled() && github.event_name == 'workflow_dispatch' && github.event.inputs.pr_head_sha != '' }} env: TARGET_APP_STATUS_TOKEN: ${{ steps.target_app_token.outputs.token || '' }} + GITHUB_STATUS_TOKEN: ${{ github.token }} PR_REVIEW_MERGE_STATUS_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || '' }} OPENCODE_APPROVE_STATUS_TOKEN: ${{ secrets.OPENCODE_APPROVE_TOKEN || '' }} TARGET_REPOSITORY: ${{ github.event.inputs.target_repository || github.repository }} @@ -793,6 +804,13 @@ jobs: if post_strix_status "opencode-approve-token" "$OPENCODE_APPROVE_STATUS_TOKEN"; then exit 0 fi + if [ "$TARGET_REPOSITORY" = "$GITHUB_REPOSITORY" ]; then + if post_strix_status "github-token" "$GITHUB_STATUS_TOKEN"; then + exit 0 + fi + else + echo "::notice::Skipping github-token fallback for cross-repository Strix status publish." + 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/opencode_review_approve_gate.sh b/scripts/ci/opencode_review_approve_gate.sh index 392343397..03828be1d 100755 --- a/scripts/ci/opencode_review_approve_gate.sh +++ b/scripts/ci/opencode_review_approve_gate.sh @@ -84,23 +84,41 @@ import sys from pathlib import Path -def fail() -> None: +def reject(reason: str) -> None: + print(f"Reason: {reason}", file=sys.stderr) raise SystemExit(1) +control_path = Path(sys.argv[1]) +try: + control = json.loads(control_path.read_text(encoding="utf-8")) +except (OSError, json.JSONDecodeError) as exc: + reject(f"control JSON is invalid: {exc}") + +if not isinstance(control, dict): + reject("control JSON must be an object") + + 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") +def required_string(field: str) -> str: + value = control.get(field) + if not nonempty_string(value): + reject(f"{field} must be a non-empty string") + return str(value) + + +def validate_finding(index: int, finding: object) -> None: + if not isinstance(finding, dict): + reject(f"finding {index} must be an object") + path = finding.get("path") if not nonempty_string(path): - return False + reject(f"finding {index} path must be a non-empty string") if str(path).casefold() in {"n/a", "unknown"}: - return False - line = value.get("line") + reject(f"finding {index} path must name a source file") + line = finding.get("line") if ( isinstance(line, bool) or not isinstance(line, (int, float)) @@ -108,8 +126,8 @@ def valid_finding(value: object) -> bool: or line <= 0 or math.floor(float(line)) != float(line) ): - return False - required_strings = ( + reject(f"finding {index} line must be a positive integer") + for field in ( "severity", "title", "problem", @@ -117,43 +135,37 @@ def valid_finding(value: object) -> bool: "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 not nonempty_string(finding.get(field)): + reject(f"finding {index} field {field} must be a non-empty string") + suggested_diff = str(finding.get("suggested_diff", "")).casefold() if suggested_diff.startswith("n/a") or suggested_diff.startswith("cannot provide diff"): - return False - return True - + reject(f"finding {index} suggested_diff must be concrete") -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() +head_sha = required_string("head_sha") +run_id = required_string("run_id") +run_attempt = required_string("run_attempt") +required_string("reason") +required_string("summary") result = control.get("result") if result not in {"APPROVE", "REQUEST_CHANGES"}: - fail() + reject("result must be APPROVE or REQUEST_CHANGES") findings = control.get("findings") if result == "REQUEST_CHANGES": if not isinstance(findings, list) or len(findings) == 0: - fail() + reject("REQUEST_CHANGES requires at least one finding") elif findings is not None and (not isinstance(findings, list) or len(findings) != 0): - fail() + reject("APPROVE requires findings to be empty") -if not all(valid_finding(finding) for finding in (findings or [])): - fail() +for index, finding in enumerate(findings or [], start=1): + validate_finding(index, finding) -print(control["head_sha"]) -print(control["run_id"]) -print(control["run_attempt"]) +print(head_sha) +print(run_id) +print(run_attempt) print(result) PY then diff --git a/scripts/ci/strix_required_workflow_smoke.sh b/scripts/ci/strix_required_workflow_smoke.sh index 84a4a5afb..a58ee2dca 100755 --- a/scripts/ci/strix_required_workflow_smoke.sh +++ b/scripts/ci/strix_required_workflow_smoke.sh @@ -11,7 +11,11 @@ repo_root="$( cd -P -- "$script_dir/../.." pwd -P )" -workflow_file="$repo_root/.github/workflows/strix.yml" +workflow_root="${TRUSTED_WORKSPACE:-$repo_root}" +if [ ! -f "$workflow_root/.github/workflows/strix.yml" ]; then + workflow_root="$repo_root" +fi +workflow_file="$workflow_root/.github/workflows/strix.yml" gate_script="$repo_root/scripts/ci/strix_quick_gate.sh" full_gate_test="$repo_root/scripts/ci/test_strix_quick_gate.sh" @@ -103,17 +107,10 @@ for line in lines[jobs_index + 1 :]: if line.strip(): inside_permissions = False -if status_write_jobs: +if status_write_jobs != ["strix"]: print( - "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.", + "Strix workflow must scope statuses: write only to the strix scan job; found: " + + (", ".join(status_write_jobs) if status_write_jobs else "none"), file=sys.stderr, ) raise SystemExit(1) @@ -127,6 +124,8 @@ if ! bash -n "$gate_script" "$full_gate_test"; then record_failure "Strix gate scripts must pass bash syntax checks" fi +echo "Checking Strix workflow contract in $workflow_file" + checkout_count="$(grep -Fc "uses: actions/checkout@" "$workflow_file" || true)" if [ "$checkout_count" != "1" ]; then record_failure "Strix workflow must use actions/checkout exactly once for central trusted source checkout" diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 1f81a8713..9cd2956f2 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -98,10 +98,10 @@ assert_strix_workflow_pr_trigger_hardened() { assert_file_contains "$workflow_file" "branches: [main, develop, master]" "strix workflow scans GitHub Flow and Git Flow protected branches" assert_file_contains "$workflow_file" "pull_request_target:" "strix workflow uses trusted PR trigger" assert_file_contains "$workflow_file" "format('pr-{0}-{1}', github.event.pull_request.number, github.event.pull_request.head.sha)" "strix workflow scopes pull_request_target concurrency to the active pull request head" - assert_file_contains "$workflow_file" 'strix-${{ github.event.inputs.target_repository || github.event.pull_request.base.repo.full_name || github.repository }}' "strix manual dispatch concurrency scopes to the target repository when provided" + assert_file_contains "$workflow_file" "github.event.inputs.target_repository ||" "strix manual dispatch concurrency scopes to the target repository when provided" assert_file_contains "$workflow_file" "format('pr-{0}-{1}', github.event.inputs.pr_number, github.event.inputs.pr_head_sha)" "strix workflow scopes manual PR evidence concurrency to the requested pull request head" assert_file_contains "$workflow_file" "github.event.inputs.pr_number != '' && format('pr-{0}', github.event.inputs.pr_number)" "strix workflow retains a manual PR fallback group when no head SHA is provided" - assert_file_contains "$workflow_file" "|| github.ref" "strix workflow scopes non-PR concurrency to the current ref" + assert_file_contains "$workflow_file" "github.ref }}" "strix workflow scopes non-PR concurrency to the current ref" assert_file_contains "$workflow_file" "cancel-in-progress: \${{ github.event_name == 'pull_request_target' && github.event.action == 'closed' }}" "strix workflow cancels only closed-PR cleanup runs" assert_file_contains "$workflow_file" "cancel-in-progress stays disabled for normal PR updates" "strix workflow documents normal PR security evidence preservation" assert_file_contains "$workflow_file" "refs/pull//head has already advanced before this queued run starts" "strix workflow documents stale scan queue avoidance" @@ -132,8 +132,10 @@ assert_strix_workflow_pr_trigger_hardened() { assert_file_contains "$workflow_file" "git -C \"\$TRUSTED_WORKSPACE\"" "strix workflow runs git only inside trusted workspace" assert_file_contains "$workflow_file" 'working-directory: ${{ runner.temp }}/trusted-workspace' "strix workflow executes privileged steps from the trusted workspace" assert_file_contains "$workflow_file" 'mkdir -p "$TRUSTED_WORKSPACE/scripts/ci"' "strix workflow creates the scheduler policy directory before materializing PR-head scheduler policy" + assert_file_contains "$workflow_file" 'git -C "$TRUSTED_WORKSPACE" show "$PR_HEAD_SHA:.github/workflows/strix.yml" > "$TRUSTED_WORKSPACE/.github/workflows/strix.yml"' "strix workflow materializes the PR-head workflow for required-path self-test" assert_file_contains "$workflow_file" "STRIX_REPO_ROOT:" "strix workflow passes target repository root to the central Strix gate" assert_file_contains "$workflow_file" "bash \"\$TRUSTED_STRIX_REQUIRED_SMOKE\"" "strix workflow self-test executes bounded trusted smoke script" + assert_file_contains "$REPO_ROOT/scripts/ci/strix_required_workflow_smoke.sh" 'TRUSTED_WORKSPACE' "strix required-workflow smoke validates the fetched PR head workflow when available" assert_file_not_contains "$workflow_file" "bash \"\$TRUSTED_STRIX_GATE_TEST\"" "strix required path does not execute the full long-form gate harness" assert_file_contains "$workflow_file" "bash \"\$TRUSTED_STRIX_GATE\"" "strix workflow executes trusted temp gate script" assert_file_contains "$workflow_file" "Collect Strix reports for artifact upload" "strix workflow preserves reports from trusted workspace" @@ -885,7 +887,7 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$workflow_file" 'branch protection cannot observe an approval gate without a matching GitHub pull review' "opencode approval logs why approval publication failure is blocking" assert_file_contains "$workflow_file" 'OpenCode approve review publication failed for head %s; branch protection still lacks the required GitHub review.' "opencode approval explains failed-closed review publication" assert_file_not_contains "$workflow_file" 'OpenCode approve review publication skipped after successful gate; keeping the successful approval gate result' "opencode approval no longer soft-passes rejected APPROVE review writes" - assert_file_contains "$workflow_file" 'Branch protection remains authoritative for required reviews and peer checks.' "opencode approval logs that branch protection remains authoritative after review write failure" + assert_file_contains "$workflow_file" 'Branch protection: remains authoritative for required reviews and peer checks.' "opencode approval logs that branch protection remains authoritative after review write failure" assert_file_not_contains "$workflow_file" 'gh_error_is_rate_limited()' "opencode approval soft-pass is event-scoped rather than rate-limit-specific" assert_file_contains "$workflow_file" 'warn_gh_publication_failure "review overview comment"' "opencode approval soft-fails permission-denied overview publication" assert_file_not_contains "$workflow_file" 'gh api -X DELETE "repos/${GH_REPOSITORY}/issues/comments/${comment_id}"' "opencode review must not delete Review Overview gate evidence" @@ -907,7 +909,7 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$workflow_file" "collect_failed_check_evidence.sh" "opencode review workflow collects failed check logs and annotations" assert_file_contains "$workflow_file" 'HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.inputs.pr_head_sha }}' "opencode evidence step passes HEAD_SHA to failed-check evidence collection" assert_file_contains "$workflow_file" "FAILED_CHECK_EVIDENCE_ATTEMPTS" "opencode review workflow bounds waiting for peer check failures before model review" - assert_file_contains "$workflow_file" 'timeout-minutes: 40' "opencode evidence preparation has a bounded peer-check wait timeout" + assert_file_contains "$workflow_file" 'timeout-minutes: 12' "opencode evidence preparation has a bounded peer-check wait timeout" assert_file_contains "$workflow_file" 'FAILED_CHECK_EVIDENCE_ATTEMPTS: "20"' "opencode review workflow keeps pre-model peer-check waiting bounded for required workflow DX" assert_file_contains "$workflow_file" 'FAILED_CHECK_EVIDENCE_SLEEP_SECONDS: "15"' "opencode review workflow retries peer-check evidence without stalling the model stage for Strix-scale durations" assert_file_contains "$workflow_file" "found completed failed peer-check evidence while other peer checks are still running" "opencode evidence preparation retries stale failed checks while peer checks are pending" diff --git a/scripts/ci/validate_opencode_failed_check_review.sh b/scripts/ci/validate_opencode_failed_check_review.sh index 9cd5d21fb..061d3bf22 100755 --- a/scripts/ci/validate_opencode_failed_check_review.sh +++ b/scripts/ci/validate_opencode_failed_check_review.sh @@ -21,28 +21,34 @@ if [ ! -s "$FAILED_CHECKS_FILE" ]; then fi review_text="$( - jq -r ' - [ - (.summary // ""), - (.reason // ""), - ( - .findings[]? - | [ - (.path // ""), - ((.line // "") | tostring), - (.severity // ""), - (.title // ""), - (.problem // ""), - (.root_cause // ""), - (.fix_direction // ""), - (.regression_test_direction // ""), - (.suggested_diff // "") - ] - | join("\n") - ) - ] - | join("\n") - ' "$CONTROL_JSON_FILE" + python3 - "$CONTROL_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")) +parts = [str(control.get("summary") or ""), str(control.get("reason") or "")] +for finding in control.get("findings") or []: + parts.append( + "\n".join( + str(finding.get(field) or "") + for field in ( + "path", + "line", + "severity", + "title", + "problem", + "root_cause", + "fix_direction", + "regression_test_direction", + "suggested_diff", + ) + ) + ) +print("\n".join(parts)) +PY )" contains_review_text() { @@ -168,23 +174,36 @@ extract_strix_report_model_markers() { } count_strix_review_findings() { - jq -r ' - [ - (.findings // [])[] - | [ - .title, - .problem, - .root_cause, - .fix_direction, - .regression_test_direction, - .suggested_diff - ] - | map(. // "") - | join("\n") - | select(test("strix|github[-_]models/|deepseek/|openai/gpt-|vertex_ai/|Vulnerability Report"; "i")) - ] - | length - ' "$CONTROL_JSON_FILE" + python3 - "$CONTROL_JSON_FILE" <<'PY' +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +control = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +pattern = re.compile( + r"strix|github[-_]models/|deepseek/|openai/gpt-|vertex_ai/|Vulnerability Report", + re.IGNORECASE, +) +count = 0 +for finding in control.get("findings") or []: + text = "\n".join( + str(finding.get(field) or "") + for field in ( + "title", + "problem", + "root_cause", + "fix_direction", + "regression_test_direction", + "suggested_diff", + ) + ) + if pattern.search(text): + count += 1 +print(count) +PY } validate_distinct_strix_report_findings() {