Strix Security Scan ContextualWisdomLab/ContextualWisdomLab.github.io#200@b2ba873c20dbeba2bd4b0c4f59e35b4c09c4d609 #467
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Strix Security Scan | |
| run-name: >- | |
| Strix Security Scan ${{ github.event.client_payload.target_repository || | |
| github.event.pull_request.base.repo.full_name || github.repository }}#${{ | |
| github.event.client_payload.pr_number || github.event.pull_request.number || 'event' }}@${{ | |
| github.event.client_payload.pr_head_sha || github.event.pull_request.head.sha || github.sha }} | |
| on: | |
| push: | |
| branches: [main, develop, master] | |
| # Skip scans for changes that touch ONLY non-executable documentation and | |
| # image assets. A change whose entire diff is these paths has no source, | |
| # build, config, or workflow logic for a code security scanner to analyze, | |
| # so skipping it loses no coverage while freeing shared runner capacity. | |
| # Conservative by design: only file EXTENSIONS/paths that can never contain | |
| # executable logic are listed (no source, no *.txt, no *.svg, no CODEOWNERS, | |
| # no build scripts). A diff touching even one non-listed file still scans. | |
| # The weekly full-tree schedule below re-scans protected branches with no | |
| # path filter, backstopping every path. | |
| paths-ignore: | |
| - '**/*.md' | |
| - '**/*.markdown' | |
| - '**/*.rst' | |
| - '**/*.png' | |
| - '**/*.jpg' | |
| - '**/*.jpeg' | |
| - '**/*.gif' | |
| - '**/*.webp' | |
| - '**/*.bmp' | |
| - '**/*.ico' | |
| - 'LICENSE' | |
| - 'LICENSE.*' | |
| - 'COPYING' | |
| - '.github/ISSUE_TEMPLATE/**' | |
| pull_request_target: | |
| types: [opened, synchronize, reopened, ready_for_review, closed] | |
| # Same conservative doc/image-only skip for PR scans. GitHub evaluates these | |
| # path filters against the PR's full base..head diff, so a PR is skipped only | |
| # when EVERY changed file is a non-executable doc/image asset; any code, | |
| # config, build, or workflow change still triggers the scan. The run-name | |
| # includes the PR number and head SHA for status grouping, while the | |
| # concurrency group is scoped per repository and event class to prevent | |
| # shared-provider key rate-limit storms. Strix runs intentionally do not | |
| # cancel in progress because a pre-job cancellation leaves no scanner log to | |
| # review. GitHub keeps one active and one pending run per group; the merge | |
| # scheduler re-dispatches exact-head evidence when a pending run is | |
| # superseded. For PRs the merge scheduler manages, same-head Strix evidence | |
| # is still forced at merge time via repository_dispatch (which paths-ignore | |
| # does not affect), so merged code never loses evidence. | |
| paths-ignore: | |
| - '**/*.md' | |
| - '**/*.markdown' | |
| - '**/*.rst' | |
| - '**/*.png' | |
| - '**/*.jpg' | |
| - '**/*.jpeg' | |
| - '**/*.gif' | |
| - '**/*.webp' | |
| - '**/*.bmp' | |
| - '**/*.ico' | |
| - 'LICENSE' | |
| - 'LICENSE.*' | |
| - 'COPYING' | |
| - '.github/ISSUE_TEMPLATE/**' | |
| schedule: | |
| # Weekly scan on protected branches (Mondays at 03:00 UTC). | |
| - cron: '0 3 * * 1' | |
| # Default-branch-only retry entrypoint; no caller-selected workflow ref. | |
| repository_dispatch: | |
| types: [strix-scan] | |
| concurrency: | |
| # Include the event name so default-branch repository_dispatch evidence cannot cancel | |
| # or interleave with the required pull_request_target Strix context that branch | |
| # protection reads. Closed PR events use a separate group so their cancellation | |
| # job can run immediately instead of waiting behind the scan it must cancel. | |
| # | |
| # Rate-limit root-cause fix (2026-08-24): the group is scoped per REPOSITORY | |
| # (not per PR) so sibling pull requests in the same repository scan | |
| # sequentially instead of concurrently. Concurrent per-PR scans each retry | |
| # the shared NVIDIA NIM key up to three times, producing guaranteed | |
| # litellm.RateLimitError storms and fail-closed gate failures across every | |
| # open PR (observed 2026-08-23/24). Serializing per repository and event | |
| # class keeps at most one provider-backed PR scan in flight per class. Push | |
| # and scheduled scans retain the branch ref so one protected branch cannot | |
| # supersede another branch's pending evidence. GitHub's native concurrency | |
| # contract retains one active and one pending run; the scheduler re-dispatches | |
| # the exact current head after pending-run supersession, and accuracy is | |
| # prioritized over scan latency. | |
| group: >- | |
| strix-${{ | |
| github.event_name == 'pull_request_target' && | |
| github.event.action == 'closed' && | |
| format('closed-pr-{0}-{1}', github.event.pull_request.base.repo.full_name, github.event.pull_request.number) || | |
| (github.event_name == 'pull_request_target' || github.event_name == 'repository_dispatch') && | |
| format('{0}-{1}', github.event_name, github.event.client_payload.target_repository || github.event.pull_request.base.repo.full_name || github.repository) || | |
| format('{0}-{1}-{2}', github.event_name, github.repository, github.ref) | |
| }} | |
| cancel-in-progress: false | |
| # Scorecard Token-Permissions (alert #43): keep the workflow-level token | |
| # read-only and scope same-repo status publication to the Strix scan job. | |
| permissions: | |
| actions: read | |
| contents: read | |
| models: read | |
| jobs: | |
| cancel-closed-pr-runs: | |
| if: github.event_name == 'pull_request_target' && github.event.action == 'closed' | |
| runs-on: ubuntu-latest | |
| # Prefer the established scheduler credential, but let the close event use | |
| # its job-scoped token so abandoned scans are cancelled even when that | |
| # optional secret is unavailable. This job never checks out PR code. | |
| permissions: | |
| actions: write | |
| contents: read | |
| env: | |
| GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || github.token }} | |
| TARGET_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name || github.repository }} | |
| CLOSED_PR_NUMBER: ${{ github.event.pull_request.number }} | |
| CLOSED_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| CURRENT_RUN_ID: ${{ github.run_id }} | |
| steps: | |
| - name: Cancel queued and running scans for the closed pull request | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| cancel_runs() { | |
| local status="$1" | |
| local runs_url="repos/${TARGET_REPOSITORY}/actions/runs?status=${status}&per_page=100" | |
| local runs_json | |
| if ! runs_json="$(gh api --paginate "$runs_url" 2>/tmp/strix-close-gh-error)"; then | |
| echo "::warning::Strix close cleanup could not inspect ${TARGET_REPOSITORY}; leaving runs unchanged." | |
| sed 's/^/ /' /tmp/strix-close-gh-error >&2 || true | |
| return 0 | |
| fi | |
| local run_ids | |
| if ! run_ids="$(jq -r --arg pr "$CLOSED_PR_NUMBER" --arg head_sha "$CLOSED_PR_HEAD_SHA" \ | |
| --arg current "$CURRENT_RUN_ID" ' | |
| .workflow_runs[] | |
| | select((.id | tostring) != $current) | |
| | select(.name == "Strix Security Scan") | |
| | select(.event == "pull_request_target") | |
| | select(.head_sha == $head_sha or any(.pull_requests[]?; ((.number | tostring) == $pr))) | |
| | .id | |
| ' <<<"$runs_json")"; then | |
| echo "::warning::Strix close cleanup received invalid run data for ${TARGET_REPOSITORY}; leaving runs unchanged." | |
| return 0 | |
| fi | |
| while IFS= read -r run_id; do | |
| [ -n "$run_id" ] || continue | |
| if gh api --method POST "repos/${TARGET_REPOSITORY}/actions/runs/${run_id}/cancel" >/dev/null 2>/tmp/strix-close-cancel-error; then | |
| echo "Cancelled Strix run ${run_id} in ${TARGET_REPOSITORY} for closed PR #${CLOSED_PR_NUMBER}." | |
| else | |
| echo "::warning::Strix close cleanup could not cancel run ${run_id} in ${TARGET_REPOSITORY}; it may have finished or the credential lacks Actions write access." | |
| sed 's/^/ /' /tmp/strix-close-cancel-error >&2 || true | |
| fi | |
| done <<<"$run_ids" | |
| } | |
| for active_status in queued in_progress requested waiting pending; do | |
| cancel_runs "$active_status" | |
| done | |
| strix: | |
| if: github.event_name != 'pull_request_target' || github.event.action != 'closed' | |
| # Large repositories can require a legitimate full-hour review. The scanner | |
| # gets a 90-minute process budget and a 95-minute total retry budget; the | |
| # 100-minute step and 120-minute job leave deterministic time to preserve | |
| # partial reports and publish a concrete failure reason. Hitting any cap is | |
| # fail-closed and never turns an incomplete scan into an approval. | |
| timeout-minutes: 120 | |
| runs-on: ubuntu-latest | |
| # Least-privilege token scoped to this job (Scorecard alert #43): the scan | |
| # exchanges an OIDC token (id-token) and publishes same-repo status evidence | |
| # from the scan job only. | |
| permissions: | |
| actions: read | |
| contents: read | |
| id-token: write | |
| models: read | |
| statuses: write | |
| env: | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true | |
| steps: | |
| - name: Harden runner | |
| uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 | |
| with: | |
| egress-policy: audit | |
| disable-file-monitoring: true | |
| - name: Set up Python | |
| uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 | |
| with: | |
| python-version: "3.13" | |
| - name: Resolve trusted Strix source ref | |
| id: trusted_source | |
| env: | |
| JOB_CONTEXT_JSON: ${{ toJSON(job) }} | |
| GITHUB_CONTEXT_JSON: ${{ toJSON(github) }} | |
| run: | | |
| set -euo pipefail | |
| python3 <<'PY' >>"$GITHUB_OUTPUT" | |
| import json | |
| import os | |
| import re | |
| import sys | |
| try: | |
| job_context = json.loads(os.environ.get("JOB_CONTEXT_JSON") or "{}") | |
| github_context = json.loads(os.environ.get("GITHUB_CONTEXT_JSON") or "{}") | |
| except json.JSONDecodeError as exc: | |
| print(f"::error::Could not parse GitHub workflow context JSON: {exc}", file=sys.stderr) | |
| raise SystemExit(1) | |
| trusted_repository = str( | |
| job_context.get("workflow_repository") or "ContextualWisdomLab/.github" | |
| ).strip() | |
| trusted_ref = str( | |
| job_context.get("workflow_sha") or github_context.get("workflow_sha") or "" | |
| ).strip() | |
| workflow_ref = str( | |
| job_context.get("workflow_ref") or github_context.get("workflow_ref") or "" | |
| ).strip() | |
| if not trusted_ref: | |
| trusted_ref = "main" | |
| prefix = "ContextualWisdomLab/.github/.github/workflows/strix.yml@" | |
| if workflow_ref.startswith(prefix): | |
| trusted_ref = workflow_ref.split("@", 1)[1] | |
| if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", trusted_repository): | |
| print("::error::Trusted workflow repository resolved to an invalid name.", file=sys.stderr) | |
| raise SystemExit(1) | |
| if not re.fullmatch(r"[0-9a-fA-F]{40}|refs/[^\s]+|[A-Za-z0-9._/-]+", trusted_ref): | |
| print("::error::Trusted workflow ref resolved to an invalid value.", file=sys.stderr) | |
| raise SystemExit(1) | |
| print(f"repository={trusted_repository}") | |
| print(f"ref={trusted_ref}") | |
| PY | |
| - name: Checkout trusted Strix source | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| repository: ${{ steps.trusted_source.outputs.repository }} | |
| fetch-depth: 1 | |
| persist-credentials: false | |
| ref: ${{ steps.trusted_source.outputs.ref }} | |
| path: trusted-strix-source | |
| - name: Export trusted Strix source paths | |
| run: | | |
| set -euo pipefail | |
| trusted_strix_source="$GITHUB_WORKSPACE/trusted-strix-source" | |
| test -f "$trusted_strix_source/scripts/ci/strix_quick_gate.sh" | |
| test -f "$trusted_strix_source/scripts/ci/test_strix_quick_gate.sh" | |
| test -f "$trusted_strix_source/scripts/ci/strix_required_workflow_smoke.sh" | |
| { | |
| echo "TRUSTED_STRIX_SOURCE=$trusted_strix_source" | |
| echo "TRUSTED_STRIX_GATE=$trusted_strix_source/scripts/ci/strix_quick_gate.sh" | |
| echo "TRUSTED_STRIX_GATE_TEST=$trusted_strix_source/scripts/ci/test_strix_quick_gate.sh" | |
| echo "TRUSTED_STRIX_REQUIRED_SMOKE=$trusted_strix_source/scripts/ci/strix_required_workflow_smoke.sh" | |
| } >> "$GITHUB_ENV" | |
| - name: Exchange OpenCode app token for target repository reads | |
| id: target_app_token | |
| env: | |
| OIDC_AUDIENCE: opencode-github-action | |
| OPENCODE_API_BASE_URL: https://api.opencode.ai | |
| run: | | |
| set -euo pipefail | |
| mark_unavailable() { | |
| echo "available=false" >>"$GITHUB_OUTPUT" | |
| } | |
| if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then | |
| echo "OpenCode app token exchange unavailable: OIDC request environment is missing." | |
| mark_unavailable | |
| exit 0 | |
| fi | |
| request_url="${ACTIONS_ID_TOKEN_REQUEST_URL}" | |
| separator="&" | |
| case "$request_url" in | |
| *\?*) ;; | |
| *) separator="?" ;; | |
| esac | |
| if ! oidc_response="$( | |
| curl -fsS \ | |
| -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ | |
| "${request_url}${separator}audience=${OIDC_AUDIENCE}" | |
| )"; then | |
| echo "OpenCode app token exchange unavailable: OIDC token request did not complete." | |
| mark_unavailable | |
| exit 0 | |
| fi | |
| oidc_token="$(jq -r '.value // empty' <<<"$oidc_response")" | |
| if [ -z "$oidc_token" ]; then | |
| echo "OpenCode app token exchange unavailable: OIDC token response was empty." | |
| mark_unavailable | |
| exit 0 | |
| fi | |
| if ! token_response="$( | |
| curl -fsS \ | |
| -X POST \ | |
| -H "Authorization: Bearer ${oidc_token}" \ | |
| "${OPENCODE_API_BASE_URL}/exchange_github_app_token" | |
| )"; then | |
| echo "OpenCode app token exchange unavailable: app token request did not complete." | |
| mark_unavailable | |
| exit 0 | |
| fi | |
| app_token="$(jq -r '.token // empty' <<<"$token_response")" | |
| if [ -z "$app_token" ]; then | |
| echo "OpenCode app token exchange unavailable: app token response was empty." | |
| mark_unavailable | |
| exit 0 | |
| fi | |
| echo "::add-mask::$app_token" | |
| { | |
| echo "available=true" | |
| echo "token=$app_token" | |
| } >>"$GITHUB_OUTPUT" | |
| - name: Resolve target repository visibility | |
| id: target_visibility | |
| env: | |
| GH_TOKEN: ${{ steps.target_app_token.outputs.token || secrets.OPENCODE_APPROVE_TOKEN || github.token }} | |
| TARGET_REPOSITORY: ${{ github.event.client_payload.target_repository || github.event.pull_request.base.repo.full_name || github.repository }} | |
| EVENT_REPOSITORY_VISIBILITY: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.repo.visibility || github.event_name != 'repository_dispatch' && github.event.repository.visibility || '' }} | |
| run: | | |
| set -euo pipefail | |
| if [[ ! "$TARGET_REPOSITORY" =~ ^ContextualWisdomLab/[A-Za-z0-9_.-]+$ ]]; then | |
| echo "::error::Strix target repository must belong to ContextualWisdomLab." | |
| exit 1 | |
| fi | |
| case "$EVENT_REPOSITORY_VISIBILITY" in | |
| PUBLIC | public) is_private=false ;; | |
| PRIVATE | private | INTERNAL | internal) is_private=true ;; | |
| "") | |
| is_private="" | |
| for target_visibility_attempt in 1 2 3 4 5 6; do | |
| # The single-quoted jq program intentionally expands jq's | |
| # `$visibility`, not a shell variable (ShellCheck SC2016). | |
| # shellcheck disable=SC2016 | |
| if is_private="$( | |
| gh api "repos/${TARGET_REPOSITORY}" --jq ' | |
| (.visibility // "" | ascii_downcase) as $visibility | |
| | if $visibility == "public" then "false" | |
| elif $visibility == "private" or $visibility == "internal" then "true" | |
| else empty | |
| end | |
| ' | |
| )"; then | |
| break | |
| fi | |
| is_private="" | |
| if [ "$target_visibility_attempt" -lt 6 ]; then | |
| echo "Repository visibility lookup failed (attempt ${target_visibility_attempt}/6), possibly a transient GitHub API rate limit; retrying after backoff." >&2 | |
| sleep "$(( target_visibility_attempt * 5 ))" | |
| fi | |
| done | |
| ;; | |
| *) | |
| echo "::error::Target repository event visibility was not public, private, or internal." | |
| exit 1 | |
| ;; | |
| esac | |
| case "$is_private" in | |
| true | false) ;; | |
| *) | |
| echo "::error::Target repository visibility did not resolve to true or false after retries." | |
| exit 1 | |
| ;; | |
| esac | |
| echo "is_private=$is_private" >>"$GITHUB_OUTPUT" | |
| - name: Materialize target workspace | |
| if: github.event_name != 'repository_dispatch' | |
| env: | |
| GH_TOKEN: ${{ steps.target_app_token.outputs.token || secrets.OPENCODE_APPROVE_TOKEN || github.token }} | |
| REPOSITORY: ${{ github.event.pull_request.base.repo.full_name || github.repository }} | |
| TARGET_WORKSPACE_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }} | |
| run: | | |
| set -euo pipefail | |
| trusted_workspace="$RUNNER_TEMP/trusted-workspace" | |
| mkdir -p "$trusted_workspace" | |
| git init -q "$trusted_workspace" | |
| gh auth setup-git | |
| git -C "$trusted_workspace" remote add origin "$GITHUB_SERVER_URL/$REPOSITORY.git" | |
| git -C "$trusted_workspace" fetch --no-tags --depth=1 origin "$TARGET_WORKSPACE_SHA" | |
| git -C "$trusted_workspace" checkout --detach --quiet "$TARGET_WORKSPACE_SHA" | |
| git -C "$trusted_workspace" cat-file -e "$TARGET_WORKSPACE_SHA^{commit}" | |
| echo "TRUSTED_WORKSPACE=$trusted_workspace" >> "$GITHUB_ENV" | |
| - name: Validate repository dispatch against live pull request metadata | |
| if: github.event_name == 'repository_dispatch' | |
| env: | |
| GH_TOKEN: ${{ steps.target_app_token.outputs.token || secrets.OPENCODE_APPROVE_TOKEN || github.token }} | |
| REPOSITORY: ${{ github.event.client_payload.target_repository }} | |
| PR_NUMBER: ${{ github.event.client_payload.pr_number }} | |
| SUPPLIED_BASE_REF: ${{ github.event.client_payload.pr_base_ref }} | |
| SUPPLIED_BASE_SHA: ${{ github.event.client_payload.pr_base_sha }} | |
| SUPPLIED_HEAD_SHA: ${{ github.event.client_payload.pr_head_sha }} | |
| run: | | |
| set -euo pipefail | |
| if ! [[ "$REPOSITORY" =~ ^ContextualWisdomLab/[A-Za-z0-9_.-]+$ ]] || | |
| ! [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || | |
| ! [[ "$SUPPLIED_BASE_SHA" =~ ^[0-9a-fA-F]{40}$ ]] || | |
| ! [[ "$SUPPLIED_HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]] || | |
| [ -z "$SUPPLIED_BASE_REF" ]; then | |
| echo "::error::repository_dispatch Strix metadata is incomplete or malformed." | |
| exit 1 | |
| fi | |
| pull_request_json="$(gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}")" | |
| live_state="$(jq -r '.state // empty' <<<"$pull_request_json")" | |
| live_base_repository="$(jq -r '.base.repo.full_name // empty' <<<"$pull_request_json")" | |
| live_head_repository="$(jq -r '.head.repo.full_name // empty' <<<"$pull_request_json")" | |
| live_base_ref="$(jq -r '.base.ref // empty' <<<"$pull_request_json")" | |
| live_base_sha="$(jq -r '.base.sha // empty' <<<"$pull_request_json")" | |
| live_head_sha="$(jq -r '.head.sha // empty' <<<"$pull_request_json")" | |
| if [ "$live_state" != "open" ] || | |
| [ "$live_base_repository" != "$REPOSITORY" ] || | |
| [ "$live_head_repository" != "$REPOSITORY" ] || | |
| [ "$live_base_ref" != "$SUPPLIED_BASE_REF" ] || | |
| [ "$live_base_sha" != "$SUPPLIED_BASE_SHA" ] || | |
| [ "$live_head_sha" != "$SUPPLIED_HEAD_SHA" ]; then | |
| printf '::error::repository_dispatch Strix metadata does not match live PR %s#%s. supplied base=%s/%s head=%s; live state=%s base_repo=%s base=%s/%s head_repo=%s head=%s.\n' \ | |
| "$REPOSITORY" "$PR_NUMBER" "$SUPPLIED_BASE_REF" "$SUPPLIED_BASE_SHA" "$SUPPLIED_HEAD_SHA" \ | |
| "${live_state:-missing}" "${live_base_repository:-missing}" "${live_base_ref:-missing}" "${live_base_sha:-missing}" \ | |
| "${live_head_repository:-missing}" "${live_head_sha:-missing}" | |
| exit 1 | |
| fi | |
| trusted_workspace="$RUNNER_TEMP/trusted-workspace" | |
| mkdir -p "$trusted_workspace" | |
| git init -q "$trusted_workspace" | |
| gh auth setup-git | |
| git -C "$trusted_workspace" remote add origin "$GITHUB_SERVER_URL/$REPOSITORY.git" | |
| git -C "$trusted_workspace" fetch --no-tags --depth=1 origin "$live_base_sha" | |
| git -C "$trusted_workspace" checkout --detach --quiet "$live_base_sha" | |
| git -C "$trusted_workspace" cat-file -e "$live_base_sha^{commit}" | |
| echo "TRUSTED_WORKSPACE=$trusted_workspace" >> "$GITHUB_ENV" | |
| - name: Fetch pull request head for trusted scan | |
| if: github.event_name == 'pull_request_target' || github.event.client_payload.pr_number != '' | |
| env: | |
| GH_TOKEN: ${{ steps.target_app_token.outputs.token || secrets.OPENCODE_APPROVE_TOKEN || github.token }} | |
| PR_NUMBER: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.event.client_payload.pr_number }} | |
| PR_BASE_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.event.client_payload.pr_base_sha }} | |
| PR_HEAD_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.event.client_payload.pr_head_sha }} | |
| run: | | |
| set -euo pipefail | |
| if [ -z "$PR_NUMBER" ] || [ -z "$PR_HEAD_SHA" ]; then | |
| echo "::error::PR number and head SHA are required for trusted PR-scope Strix evidence." | |
| exit 1 | |
| fi | |
| gh auth setup-git | |
| if ! [[ "$PR_HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then | |
| echo "::error::PR head SHA must be a 40-character git SHA." | |
| exit 1 | |
| fi | |
| if [ -n "$PR_BASE_SHA" ] && ! [[ "$PR_BASE_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then | |
| echo "::error::PR base SHA must be a 40-character git SHA." | |
| exit 1 | |
| fi | |
| if [ -n "$PR_BASE_SHA" ]; then | |
| git -C "$TRUSTED_WORKSPACE" fetch --no-tags --depth=1 origin "$PR_BASE_SHA" | |
| git -C "$TRUSTED_WORKSPACE" cat-file -e "$PR_BASE_SHA^{commit}" | |
| fi | |
| # Fetching the expected head SHA directly avoids false failures when | |
| # refs/pull/<n>/head has already advanced before this queued run starts. | |
| if git -C "$TRUSTED_WORKSPACE" fetch --no-tags --depth=1 origin "$PR_HEAD_SHA"; then | |
| git -C "$TRUSTED_WORKSPACE" cat-file -e "$PR_HEAD_SHA^{commit}" | |
| 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" | |
| fi | |
| git -C "$TRUSTED_WORKSPACE" update-ref "refs/remotes/pull/${PR_NUMBER}/head" "$PR_HEAD_SHA" | |
| exit 0 | |
| fi | |
| for pr_head_fetch_attempt in 1 2 3 4 5 6; do | |
| git -C "$TRUSTED_WORKSPACE" fetch --no-tags --prune origin "+refs/pull/${PR_NUMBER}/head:refs/remotes/pull/${PR_NUMBER}/head" | |
| fetched_head_sha="$(git -C "$TRUSTED_WORKSPACE" rev-parse "refs/remotes/pull/${PR_NUMBER}/head")" | |
| if [ "$fetched_head_sha" = "$PR_HEAD_SHA" ]; then | |
| git -C "$TRUSTED_WORKSPACE" cat-file -e "$PR_HEAD_SHA^{commit}" | |
| 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" | |
| fi | |
| exit 0 | |
| fi | |
| if [ "$pr_head_fetch_attempt" -lt 6 ]; then | |
| echo "Fetched PR head $fetched_head_sha, expected $PR_HEAD_SHA; retrying after propagation delay." >&2 | |
| sleep 10 | |
| fi | |
| done | |
| echo "::error::PR head ref did not resolve to expected commit $PR_HEAD_SHA after retries." >&2 | |
| exit 1 | |
| - name: Self-test Strix required workflow contract | |
| timeout-minutes: 2 | |
| working-directory: trusted-strix-source | |
| run: | | |
| set -euo pipefail | |
| printf 'Running bounded Strix required-workflow smoke test.\n' | |
| bash "$TRUSTED_STRIX_REQUIRED_SMOKE" | |
| - name: Materialize central Strix dependency lock from PR head | |
| if: >- | |
| github.event_name == 'pull_request_target' | |
| && github.repository == 'ContextualWisdomLab/.github' | |
| && github.event.pull_request.base.repo.full_name == 'ContextualWisdomLab/.github' | |
| && github.event.pull_request.head.repo.full_name == 'ContextualWisdomLab/.github' | |
| env: | |
| PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| run: | | |
| set -euo pipefail | |
| if ! [[ "$PR_HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then | |
| echo "::error::PR head SHA must be a 40-character git SHA." | |
| exit 1 | |
| fi | |
| if git -C "$TRUSTED_WORKSPACE" cat-file -e "$PR_HEAD_SHA:requirements-strix-ci-hashes.txt" 2>/dev/null; then | |
| git -C "$TRUSTED_WORKSPACE" show "$PR_HEAD_SHA:requirements-strix-ci-hashes.txt" > "$TRUSTED_STRIX_SOURCE/requirements-strix-ci-hashes.txt" | |
| printf 'Materialized central Strix dependency lock from same-repository PR head.\n' | |
| fi | |
| - name: Gate Strix secrets | |
| id: gate | |
| env: | |
| STRIX_MODEL: contextual-orchestrator/orchestrator/free | |
| STRIX_MODEL_REQUESTED: ${{ github.event.client_payload.strix_llm || '' }} | |
| run: | | |
| requested_model="$(printf '%s' "$STRIX_MODEL_REQUESTED" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" | |
| case "$requested_model" in | |
| ""|orchestrator/free|contextual-orchestrator/orchestrator/free) ;; | |
| *) | |
| echo '::error::Strix model overrides are limited to contextual-orchestrator/orchestrator/free.' | |
| exit 1 | |
| ;; | |
| esac | |
| strix_model="$(printf '%s' "$STRIX_MODEL" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" | |
| echo "strix_model=$strix_model" >> "$GITHUB_OUTPUT" | |
| echo 'enabled=true' >> "$GITHUB_OUTPUT" | |
| echo 'provider_mode=contextual_orchestrator' >> "$GITHUB_OUTPUT" | |
| - name: Provision contextual-orchestrator Strix sidecar | |
| if: steps.gate.outputs.enabled == 'true' | |
| env: | |
| BYTEZ_API_KEY: ${{ secrets.BYTEZ_API_KEY }} | |
| NVIDIA_NIM_API_KEY: ${{ secrets.NVIDIA_NIM_API_KEY }} | |
| NVIDIA_NIM_API_KEY_SUB: ${{ secrets.NVIDIA_NIM_API_KEY_SUB }} | |
| OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} | |
| OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} | |
| CONTEXTUAL_ORCHESTRATOR_REQUIRE_ZDR: ${{ steps.target_visibility.outputs.is_private }} | |
| CONTEXTUAL_ORCHESTRATOR_POOL: free | |
| run: | | |
| set -euo pipefail | |
| bash "$TRUSTED_STRIX_SOURCE/scripts/ci/contextual_orchestrator_review_sidecar.sh" | |
| - name: Set up Python | |
| if: steps.gate.outputs.enabled == 'true' | |
| uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 | |
| with: | |
| python-version: "3.13" | |
| - name: Install Strix | |
| if: steps.gate.outputs.enabled == 'true' | |
| working-directory: trusted-strix-source | |
| run: | | |
| set -euo pipefail | |
| # GitHub-hosted runners may inherit a collaborative umask (0002), | |
| # which makes pip-generated console scripts group-writable. Pin a | |
| # private install umask before creating the credential-bearing Strix | |
| # entry point; the runtime gate still rejects any later relaxation. | |
| umask 022 | |
| # --no-deps: strix-agent declares cryptography<49, conflicting with this repo's | |
| # cryptography==50.0.0 pin (CVE-2026-39892 fix, see requirements-strix-ci-overrides.txt). | |
| # --require-hashes already pins every package (including transitive deps) to an exact, | |
| # hash-verified version, so skipping pip's redundant declared-range resolution here is | |
| # safe -- verified locally with --dry-run against this exact file before pushing. | |
| python3 -m pip install --disable-pip-version-check --no-cache-dir --require-hashes --no-deps -r requirements-strix-ci-hashes.txt | |
| strix_executable="$(command -v strix || true)" | |
| if [ -z "$strix_executable" ] || [[ "$strix_executable" != /* ]] \ | |
| || [ ! -f "$strix_executable" ] || [ -L "$strix_executable" ] \ | |
| || [ ! -x "$strix_executable" ]; then | |
| echo "::error::Pinned Strix installation did not produce a trusted absolute executable path." | |
| exit 1 | |
| fi | |
| case "$strix_executable" in | |
| "$GITHUB_WORKSPACE"/*|"$RUNNER_TEMP"/*) | |
| echo "::error::Refusing a Strix executable from a workspace or runner-temp path." | |
| exit 1 | |
| ;; | |
| esac | |
| strix_scripts_root="$(python3 -c 'import sysconfig; print(sysconfig.get_path("scripts"))')" | |
| if [ -z "$strix_scripts_root" ] || [[ "$strix_scripts_root" != /* ]] \ | |
| || [ ! -d "$strix_scripts_root" ] || [ -L "$strix_scripts_root" ]; then | |
| echo "::error::Pinned Strix installation did not produce a trusted absolute scripts root." | |
| exit 1 | |
| fi | |
| case "$strix_executable" in | |
| "$strix_scripts_root"/*) ;; | |
| *) | |
| echo "::error::Pinned Strix executable is outside the trusted scripts root." | |
| exit 1 | |
| ;; | |
| esac | |
| # pip and the hosted tool cache can preserve collaborative write bits | |
| # even after a private install umask. Normalize both the containing | |
| # scripts root and resolved console script before pinning their | |
| # identity; the runtime gate still fails closed on later relaxation. | |
| chmod go-w -- "$strix_scripts_root" "$strix_executable" | |
| strix_executable_sha256="$(python3 - "$strix_executable" <<'PY' | |
| import hashlib | |
| from pathlib import Path | |
| import sys | |
| print(hashlib.sha256(Path(sys.argv[1]).read_bytes()).hexdigest()) | |
| PY | |
| )" | |
| { | |
| printf 'STRIX_EXECUTABLE_PATH=%s\n' "$strix_executable" | |
| printf 'STRIX_EXECUTABLE_ROOT=%s\n' "$strix_scripts_root" | |
| printf 'STRIX_EXECUTABLE_SHA256=%s\n' "$strix_executable_sha256" | |
| } >> "$GITHUB_ENV" | |
| - name: Mask LLM API key | |
| if: steps.gate.outputs.enabled == 'true' | |
| env: | |
| PROVIDER_MODE: ${{ steps.gate.outputs.provider_mode }} | |
| run: | | |
| set -euo pipefail | |
| if [ "$PROVIDER_MODE" != "contextual_orchestrator" ]; then | |
| echo '::error::Strix must use the contextual-orchestrator provider.' | |
| exit 1 | |
| fi | |
| source "$TRUSTED_STRIX_SOURCE/scripts/ci/load_contextual_orchestrator_token.sh" | |
| # Sanitize CR/LF before masking to prevent broken ::add-mask:: | |
| # commands and potential workflow command injection. | |
| sanitized="$(printf '%s' "${CONTEXTUAL_ORCHESTRATOR_TOKEN:-}" | tr -d '\r\n')" | |
| if [ -n "$sanitized" ]; then | |
| echo "::add-mask::${sanitized}" | |
| trimmed="$(printf '%s' "$sanitized" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" | |
| if [ -n "$trimmed" ] && [ "$trimmed" != "$sanitized" ]; then | |
| echo "::add-mask::${trimmed}" | |
| fi | |
| fi | |
| - name: Prepare LLM API key input file | |
| if: steps.gate.outputs.enabled == 'true' | |
| env: | |
| PROVIDER_MODE: ${{ steps.gate.outputs.provider_mode }} | |
| run: | | |
| set -euo pipefail | |
| if [ "$PROVIDER_MODE" != "contextual_orchestrator" ]; then | |
| echo '::error::Strix must use the contextual-orchestrator provider.' | |
| exit 1 | |
| fi | |
| source "$TRUSTED_STRIX_SOURCE/scripts/ci/load_contextual_orchestrator_token.sh" | |
| sanitized="$(printf '%s' "${CONTEXTUAL_ORCHESTRATOR_TOKEN:-}" | tr -d '\r\n')" | |
| trimmed="$(printf '%s' "$sanitized" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" | |
| if [ -z "$trimmed" ]; then | |
| echo '::error::CONTEXTUAL_ORCHESTRATOR_TOKEN is required for Strix scans.' | |
| exit 1 | |
| fi | |
| umask 077 | |
| llm_api_key_file="$RUNNER_TEMP/llm_api_key.txt" | |
| printf '%s' "$trimmed" > "$llm_api_key_file" | |
| echo "LLM_API_KEY_FILE=$llm_api_key_file" >> "$GITHUB_ENV" | |
| - name: Prepare contextual-orchestrator API base | |
| if: steps.gate.outputs.provider_mode == 'contextual_orchestrator' | |
| run: | | |
| set -euo pipefail | |
| sidecar_base="${CONTEXTUAL_ORCHESTRATOR_BASE_URL:-}" | |
| if [ "$sidecar_base" != "http://127.0.0.1:18080" ]; then | |
| echo '::error::Strix sidecar base URL is not the pinned local gateway origin.' | |
| exit 1 | |
| fi | |
| umask 077 | |
| llm_api_base_file="$RUNNER_TEMP/llm_api_base.txt" | |
| printf '%s/v1' "${sidecar_base%/}" > "$llm_api_base_file" | |
| echo "LLM_API_BASE_FILE=$llm_api_base_file" >> "$GITHUB_ENV" | |
| - name: Prepare Strix model input file | |
| if: steps.gate.outputs.enabled == 'true' | |
| env: | |
| STRIX_MODEL: ${{ steps.gate.outputs.strix_model }} | |
| run: | | |
| umask 077 | |
| strix_llm_file="$RUNNER_TEMP/strix_llm.txt" | |
| strix_model="$(printf '%s' "$STRIX_MODEL" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" | |
| case "$strix_model" in | |
| orchestrator/free | contextual-orchestrator/orchestrator/free) | |
| printf '%s' 'orchestrator/free' > "$strix_llm_file" | |
| ;; | |
| *) | |
| echo '::error::STRIX_LLM must select contextual-orchestrator/orchestrator/free.' | |
| exit 1 | |
| ;; | |
| esac | |
| echo "STRIX_LLM_FILE=$strix_llm_file" >> "$GITHUB_ENV" | |
| - name: Run Strix (quick) | |
| if: steps.gate.outputs.enabled == 'true' | |
| timeout-minutes: 100 | |
| # Security invariant for pull_request_target: execute only from the | |
| # trusted base checkout. The gate copies PR-head blobs into an isolated | |
| # temporary scope with execute bits stripped, then scans that scope as | |
| # data. PR evidence uses the __PR_SCOPE__ sentinel so the scanner target | |
| # cannot accidentally remain the trusted base checkout. | |
| working-directory: ${{ runner.temp }}/trusted-workspace | |
| env: | |
| STRIX_LLM_FILE: ${{ env.STRIX_LLM_FILE }} | |
| STRIX_REPO_ROOT: ${{ runner.temp }}/trusted-workspace | |
| LLM_API_BASE_FILE: ${{ env.LLM_API_BASE_FILE }} | |
| STRIX_LLM_DEFAULT_PROVIDER: contextual_orchestrator | |
| LLM_API_KEY_FILE: ${{ env.LLM_API_KEY_FILE }} | |
| STRIX_TARGET_PATH: ${{ (github.event_name == 'pull_request_target' || github.event.client_payload.pr_number != '') && '__PR_SCOPE__' || './' }} | |
| STRIX_SOURCE_DIRS: ". backend frontend" | |
| # The gateway auto pool is provider-diverse. Strix function tools | |
| # must not send a provider-specific reasoning setting to every route. | |
| STRIX_REASONING_EFFORT: none | |
| STRIX_LLM_MAX_RETRIES: 1 | |
| STRIX_TRANSIENT_RETRY_PER_MODEL: 2 | |
| STRIX_TRANSIENT_RETRY_BACKOFF_SECONDS: 60 | |
| # The gateway owns discovery and provider failover; Strix must not | |
| # bypass its ZDR/privacy policy with an external fallback model. | |
| STRIX_FALLBACK_MODELS: "" | |
| STRIX_FAIL_ON_PROVIDER_SIGNAL: "1" | |
| NPM_CONFIG_IGNORE_SCRIPTS: "true" | |
| PNPM_CONFIG_IGNORE_SCRIPTS: "true" | |
| YARN_ENABLE_SCRIPTS: "false" | |
| BUN_CONFIG_IGNORE_SCRIPTS: "true" | |
| STRIX_FAIL_ON_MIN_SEVERITY: MEDIUM | |
| STRIX_DISABLE_PR_SCOPING: ${{ (github.event_name == 'pull_request_target' || github.event.client_payload.pr_number != '') && '0' || '1' }} | |
| # A repository_dispatch executes in this central repository, so its | |
| # github.token cannot read the target repository's PR. Reuse the | |
| # target-app token that already validated and fetched that exact PR; | |
| # preserve the target-repository token for pull_request_target runs. | |
| GH_TOKEN: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.pr_number != '' && (steps.target_app_token.outputs.token || secrets.OPENCODE_APPROVE_TOKEN || github.token) || github.event_name == 'pull_request_target' && github.token || '' }} | |
| PR_NUMBER: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.event.client_payload.pr_number }} | |
| PR_BASE_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.event.client_payload.pr_base_sha }} | |
| PR_HEAD_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.event.client_payload.pr_head_sha }} | |
| IS_PR_EVIDENCE_RUN: ${{ (github.event_name == 'pull_request_target' || github.event.client_payload.pr_number != '') && 'true' || 'false' }} | |
| run: | | |
| budget_suffix="TIME""OUT" | |
| process_budget_seconds="5400" | |
| export "LLM_${budget_suffix}=900" | |
| export "STRIX_MEMORY_COMPRESSOR_${budget_suffix}=300" | |
| export "STRIX_PROCESS_${budget_suffix}_SECONDS=$process_budget_seconds" | |
| export "STRIX_TOTAL_${budget_suffix}_SECONDS=5700" | |
| # Recognized signals that the LLM backend was unavailable / starved. | |
| # Defined before the gate loop so the bounded retry decision below | |
| # can classify outcomes without duplicating the patterns later. | |
| backend_unavailable_signal='STRIX_PROVIDER_UNAVAILABLE|RateLimitError|Too many requests\. For more on scraping GitHub|exceeded your current quota|insufficient_quota|billing details|"status"[[:space:]]*:[[:space:]]*"RESOURCE_EXHAUSTED"|tokens_limit_reached|Request body too large|Max size:[[:space:]]*[0-9]+[[:space:]]+tokens|Error code:[[:space:]]*500[^[:cntrl:]]*internal_error|Error code:[[:space:]]*413|LLM CONNECTION FAILED|Could not establish connection to the language model|LLM warm-up failed|Configured model and fallback models were unavailable|Configured Vertex model and fallback models were unavailable|emitted provider infrastructure or failure-signal output|before provider infrastructure failure|litellm(\.exceptions)?\.NotFoundError[^[:cntrl:]]*Nvidia_nimException[^[:cntrl:]]*Error code:[[:space:]]*404|Error during penetration test: loginAsGuest failed after [0-9]+ attempts: curl exit 7: curl: \(7\) Failed to connect to 127\.0\.0\.1 port 48080' | |
| model_behavior_error_signal='(^|[^A-Za-z0-9_])(agents|pydantic_ai|strix)(\.[A-Za-z_][A-Za-z0-9_]*)*\.ModelBehaviorError([^A-Za-z0-9_]|$)' | |
| # Any evidence that a vulnerability was actually reported. Its presence | |
| # forces a hard failure so real findings are NEVER downgraded. Keep the | |
| # severity branch anchored away from identifiers so environment lines | |
| # such as STRIX_FAIL_ON_MIN_SEVERITY do not look like findings. | |
| reported_vulnerability_signal='Vulnerabilities[[:space:]]+[1-9]|(^|[^A-Za-z0-9_])severity[[:space:]]*:' | |
| # Capture the gate exit code plus its console output. The gate returns | |
| # exit 1 both for genuine blocking vulnerabilities AND for | |
| # LLM-backend-unavailable outcomes (GitHub Models "Too many requests" | |
| # rate limits, OpenAI quota starvation, 413 tokens_limit_reached, | |
| # connection/warm-up failures, and scanner ModelBehaviorError) that | |
| # could not complete a scan. Provider failure is typed infrastructure | |
| # evidence, but remains non-passing because no authoritative complete | |
| # vulnerability result exists. | |
| # | |
| # A typed provider outage with no reported vulnerability finding is | |
| # retried with bounded linear backoff inside this step so transient | |
| # provider failures do not fail the required check on the first | |
| # attempt. Genuine findings, configuration failures, and unexpected | |
| # exit codes never retry; the deadline keeps every path inside the | |
| # deterministic 120-minute job budget, and all-terminal outcomes | |
| # remain fail-closed. | |
| strix_run_log="$RUNNER_TEMP/strix_gate_console.log" | |
| : > "$strix_run_log" | |
| strix_terminal_log="$strix_run_log" | |
| strix_rc=0 | |
| strix_gate_attempt=1 | |
| strix_gate_deadline=$(( SECONDS + 6000 )) | |
| # Reserve the scanner process budget, not the gate's total wrapper | |
| # budget. The latter includes setup/cleanup overhead already spent | |
| # by the current attempt and can make every retry impossible. | |
| strix_gate_attempt_budget_seconds="$process_budget_seconds" | |
| set +e | |
| while : ; do | |
| strix_attempt_log="$RUNNER_TEMP/strix_gate_console_attempt_${strix_gate_attempt}.log" | |
| : > "$strix_attempt_log" | |
| bash "$TRUSTED_STRIX_GATE" 2>&1 | tee "$strix_attempt_log" | |
| strix_rc="${PIPESTATUS[0]}" | |
| cat "$strix_attempt_log" >> "$strix_run_log" | |
| strix_terminal_log="$strix_attempt_log" | |
| if [ "$strix_rc" -eq 0 ]; then | |
| break | |
| fi | |
| # Only exit-code 1 scan failures can be infrastructure outcomes. | |
| if [ "$strix_rc" -ne 1 ]; then | |
| break | |
| fi | |
| # Scope this attempt's retry decision to the log tail after the | |
| # last pipeline-continuation marker, exactly like the terminal | |
| # classification below: an already-exempted finding before the | |
| # marker must not mask a retryable outage after it. | |
| strix_retry_scope_log="$strix_terminal_log" | |
| if grep -Fq 'allowing pipeline continuation' "$strix_terminal_log"; then | |
| strix_retry_scope_log="$RUNNER_TEMP/strix_gate_console_tail.log" | |
| awk '/allowing pipeline continuation/{buf=""; next} {buf=buf $0 "\n"} END{printf "%s", buf}' \ | |
| "$strix_terminal_log" > "$strix_retry_scope_log" | |
| fi | |
| # A reported vulnerability is authoritative evidence: never retry | |
| # and never risk downgrading it. | |
| if grep -Eiq "$reported_vulnerability_signal" "$strix_retry_scope_log"; then | |
| break | |
| fi | |
| # Retry only recognized provider-outage / model-behavior classes. | |
| if ! grep -Eiq "$backend_unavailable_signal" "$strix_retry_scope_log" \ | |
| && ! grep -Eq "$model_behavior_error_signal" "$strix_retry_scope_log"; then | |
| break | |
| fi | |
| backoff_seconds=$(( ${STRIX_GATE_RETRY_BACKOFF_SECONDS:-90} * strix_gate_attempt )) | |
| retry_reserve_seconds=$(( strix_gate_attempt_budget_seconds + backoff_seconds )) | |
| remaining_seconds=$(( strix_gate_deadline - SECONDS )) | |
| if [ "$strix_gate_attempt" -ge 3 ] || [ "$remaining_seconds" -lt "$retry_reserve_seconds" ]; then | |
| echo "Provider-unavailable Strix attempt ${strix_gate_attempt} reached the bounded retry limit or the remaining job time budget (${remaining_seconds}s) is too small to retry; failing closed." >&2 | |
| break | |
| fi | |
| echo "Strix provider outage on attempt ${strix_gate_attempt}; retrying after ${backoff_seconds}s backoff." >&2 | |
| sleep "$backoff_seconds" | |
| strix_gate_attempt=$(( strix_gate_attempt + 1 )) | |
| done | |
| set -e | |
| if [ "$strix_rc" -eq 0 ]; then | |
| exit 0 | |
| fi | |
| # Preserve configuration failures (exit 2) and any unexpected exit | |
| # code as hard failures β only the scan-failure code (1) can be an | |
| # infrastructure/backend-unavailability outcome. | |
| if [ "$strix_rc" -ne 1 ]; then | |
| exit "$strix_rc" | |
| fi | |
| # An earlier out-of-scope/below-threshold finding may already have | |
| # been exempted by the trusted gate. Classify a later provider | |
| # outage from the tail after the last continuation marker, but keep | |
| # that incomplete later scan non-passing. | |
| strix_neutralization_scope_log="$strix_terminal_log" | |
| if grep -Fq 'allowing pipeline continuation' "$strix_terminal_log"; then | |
| strix_neutralization_scope_log="$RUNNER_TEMP/strix_gate_console_tail.log" | |
| awk '/allowing pipeline continuation/{buf=""; next} {buf=buf $0 "\n"} END{printf "%s", buf}' \ | |
| "$strix_terminal_log" > "$strix_neutralization_scope_log" | |
| fi | |
| # Classify provider/backend exhaustion only when no vulnerability | |
| # finding was emitted. Classification improves diagnosis; it never | |
| # converts an incomplete scan into passing security evidence. | |
| if ( grep -Eiq "$backend_unavailable_signal" "$strix_neutralization_scope_log" \ | |
| || grep -Eq "$model_behavior_error_signal" "$strix_neutralization_scope_log" ) \ | |
| && ! grep -Eiq "$reported_vulnerability_signal" "$strix_neutralization_scope_log"; then | |
| echo "::error title=STRIX_PROVIDER_UNAVAILABLE::Strix could not complete authoritative vulnerability analysis because its provider/backend was unavailable (rate limit, token cap, connection, warm-up, or model-behavior failure). See the strix-reports artifact and run log." | |
| exit "$strix_rc" | |
| fi | |
| echo "Strix reported security findings or failed for a non-backend reason; failing the required check (gate exit ${strix_rc})." >&2 | |
| exit "$strix_rc" | |
| - name: Collect Strix reports for artifact upload | |
| if: ${{ always() && steps.gate.outputs.enabled == 'true' }} | |
| env: | |
| PR_HEAD_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.event.client_payload.pr_head_sha }} | |
| run: | | |
| set -euo pipefail | |
| mkdir -p "$GITHUB_WORKSPACE/strix_runs" | |
| copied_reports=0 | |
| for candidate_dir in "$TRUSTED_WORKSPACE/strix_runs" "$RUNNER_TEMP/strix_runs"; do | |
| if [ -d "$candidate_dir" ] && [ -n "$(find "$candidate_dir" -mindepth 1 -print -quit)" ]; then | |
| cp -R "$candidate_dir"/. "$GITHUB_WORKSPACE/strix_runs"/ | |
| copied_reports=1 | |
| fi | |
| done | |
| if [ -f "$RUNNER_TEMP/strix_gate_console.log" ]; then | |
| cp "$RUNNER_TEMP/strix_gate_console.log" "$GITHUB_WORKSPACE/strix_runs/gate-console.log" | |
| copied_reports=1 | |
| fi | |
| if [ -n "$(find "$GITHUB_WORKSPACE/strix_runs" -mindepth 1 -print -quit)" ]; then | |
| copied_reports=1 | |
| fi | |
| if [ "$copied_reports" -eq 0 ]; then | |
| summary_head_sha="${PR_HEAD_SHA:-$GITHUB_SHA}" | |
| { | |
| echo "Strix scan completed without structured report files." | |
| echo "run_id=$GITHUB_RUN_ID" | |
| echo "head_sha=$summary_head_sha" | |
| } > "$GITHUB_WORKSPACE/strix_runs/scan-summary.txt" | |
| fi | |
| - name: Upload Strix reports artifact | |
| if: ${{ always() && steps.gate.outputs.enabled == 'true' }} | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: strix-reports | |
| path: strix_runs/ | |
| if-no-files-found: error | |
| retention-days: 5 | |
| - name: Publish same-head manual Strix status | |
| if: ${{ always() && !cancelled() && github.event_name == 'repository_dispatch' && github.event.client_payload.pr_head_sha != '' }} | |
| env: | |
| TARGET_APP_STATUS_TOKEN: ${{ steps.target_app_token.outputs.token || '' }} | |
| GITHUB_STATUS_TOKEN: ${{ (github.event.client_payload.target_repository == '' || github.event.client_payload.target_repository == github.repository) && github.token || '' }} | |
| PR_REVIEW_MERGE_STATUS_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || '' }} | |
| OPENCODE_APPROVE_STATUS_TOKEN: ${{ secrets.OPENCODE_APPROVE_TOKEN || '' }} | |
| TARGET_REPOSITORY: ${{ github.event.client_payload.target_repository || github.repository }} | |
| PR_HEAD_SHA: ${{ github.event.client_payload.pr_head_sha }} | |
| STRIX_RESULT: ${{ job.status }} | |
| run: | | |
| set -euo pipefail | |
| if ! [[ "$PR_HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then | |
| echo "::error::PR head SHA must be a 40-character git SHA." | |
| exit 1 | |
| fi | |
| case "$STRIX_RESULT" in | |
| success) | |
| state="success" | |
| description="Default-branch repository_dispatch Strix evidence passed" | |
| ;; | |
| failure|cancelled|skipped) | |
| state="failure" | |
| description="Default-branch repository_dispatch Strix evidence failed" | |
| ;; | |
| *) | |
| state="error" | |
| description="Default-branch repository_dispatch Strix evidence inconclusive" | |
| ;; | |
| esac | |
| post_strix_status() { | |
| token_label="$1" | |
| token="$2" | |
| if [ -z "$token" ]; then | |
| return 1 | |
| fi | |
| status_response="$(mktemp)" | |
| status_error="$(mktemp)" | |
| if GH_TOKEN="$token" gh api -X POST "repos/${TARGET_REPOSITORY}/statuses/${PR_HEAD_SHA}" \ | |
| -f state="$state" \ | |
| -f context="strix" \ | |
| -f description="$description" \ | |
| -f target_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \ | |
| >"$status_response" 2>"$status_error"; then | |
| rm -f "$status_response" "$status_error" | |
| echo "Published manual Strix status to ${TARGET_REPOSITORY}@${PR_HEAD_SHA} using ${token_label}." | |
| return 0 | |
| fi | |
| error_summary="$(head -n 1 "$status_error" | tr -d '\r' || true)" | |
| rm -f "$status_response" "$status_error" | |
| if [ -n "$error_summary" ]; then | |
| echo "::notice::Manual Strix status publish using ${token_label} did not succeed: ${error_summary}" | |
| else | |
| echo "::notice::Manual Strix status publish using ${token_label} did not succeed." | |
| fi | |
| return 1 | |
| } | |
| if post_strix_status "target-app-token" "$TARGET_APP_STATUS_TOKEN"; then | |
| exit 0 | |
| fi | |
| if post_strix_status "github-token" "$GITHUB_STATUS_TOKEN"; then | |
| exit 0 | |
| fi | |
| if post_strix_status "pr-review-merge-token" "$PR_REVIEW_MERGE_STATUS_TOKEN"; then | |
| exit 0 | |
| fi | |
| 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: | |
| name: publish-manual-pr-evidence-status | |
| needs: strix | |
| if: ${{ always() && !cancelled() && github.event_name == 'repository_dispatch' && github.event.client_payload.pr_head_sha != '' }} | |
| runs-on: ubuntu-latest | |
| permissions: | |
| id-token: write | |
| statuses: write # Required for downscoped OIDC status publication. | |
| steps: | |
| - name: Exchange OpenCode app token for target repository status | |
| id: target_app_token | |
| env: | |
| OIDC_AUDIENCE: opencode-github-action | |
| OPENCODE_API_BASE_URL: https://api.opencode.ai | |
| run: | | |
| set -euo pipefail | |
| mark_unavailable() { | |
| echo "available=false" >>"$GITHUB_OUTPUT" | |
| } | |
| if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then | |
| echo "OpenCode app token exchange unavailable: OIDC request environment is missing." | |
| mark_unavailable | |
| exit 0 | |
| fi | |
| request_url="${ACTIONS_ID_TOKEN_REQUEST_URL}" | |
| separator="&" | |
| case "$request_url" in | |
| *\?*) ;; | |
| *) separator="?" ;; | |
| esac | |
| if ! oidc_response="$( | |
| curl -fsS \ | |
| -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ | |
| "${request_url}${separator}audience=${OIDC_AUDIENCE}" | |
| )"; then | |
| echo "OpenCode app token exchange unavailable: OIDC token request did not complete." | |
| mark_unavailable | |
| exit 0 | |
| fi | |
| oidc_token="$(jq -r '.value // empty' <<<"$oidc_response")" | |
| if [ -z "$oidc_token" ]; then | |
| echo "OpenCode app token exchange unavailable: OIDC token response was empty." | |
| mark_unavailable | |
| exit 0 | |
| fi | |
| if ! token_response="$( | |
| curl -fsS \ | |
| -X POST \ | |
| -H "Authorization: Bearer ${oidc_token}" \ | |
| "${OPENCODE_API_BASE_URL}/exchange_github_app_token" | |
| )"; then | |
| echo "OpenCode app token exchange unavailable: app token request did not complete." | |
| mark_unavailable | |
| exit 0 | |
| fi | |
| app_token="$(jq -r '.token // empty' <<<"$token_response")" | |
| if [ -z "$app_token" ]; then | |
| echo "OpenCode app token exchange unavailable: app token response was empty." | |
| mark_unavailable | |
| exit 0 | |
| fi | |
| echo "::add-mask::$app_token" | |
| { | |
| echo "available=true" | |
| echo "token=$app_token" | |
| } >>"$GITHUB_OUTPUT" | |
| - name: Publish same-head manual Strix status | |
| env: | |
| TARGET_APP_STATUS_TOKEN: ${{ steps.target_app_token.outputs.token || '' }} | |
| GITHUB_STATUS_READ_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.client_payload.target_repository || github.repository }} | |
| PR_HEAD_SHA: ${{ github.event.client_payload.pr_head_sha }} | |
| STRIX_RESULT: ${{ needs.strix.result }} | |
| run: | | |
| set -euo pipefail | |
| if ! [[ "$PR_HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then | |
| echo "::error::PR head SHA must be a 40-character git SHA." | |
| exit 1 | |
| fi | |
| case "$STRIX_RESULT" in | |
| success) | |
| state="success" | |
| description="Default-branch repository_dispatch Strix evidence passed" | |
| ;; | |
| failure|cancelled|skipped) | |
| state="failure" | |
| description="Default-branch repository_dispatch Strix evidence failed" | |
| ;; | |
| *) | |
| state="error" | |
| description="Default-branch repository_dispatch Strix evidence inconclusive" | |
| ;; | |
| esac | |
| post_strix_status() { | |
| token_label="$1" | |
| token="$2" | |
| if [ -z "$token" ]; then | |
| return 1 | |
| fi | |
| status_response="$(mktemp)" | |
| status_error="$(mktemp)" | |
| if GH_TOKEN="$token" gh api -X POST "repos/${TARGET_REPOSITORY}/statuses/${PR_HEAD_SHA}" \ | |
| -f state="$state" \ | |
| -f context="strix" \ | |
| -f description="$description" \ | |
| -f target_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \ | |
| >"$status_response" 2>"$status_error"; then | |
| rm -f "$status_response" "$status_error" | |
| echo "Published manual Strix status to ${TARGET_REPOSITORY}@${PR_HEAD_SHA} using ${token_label}." | |
| return 0 | |
| fi | |
| error_summary="$(head -n 1 "$status_error" | tr -d '\r' || true)" | |
| rm -f "$status_response" "$status_error" | |
| if [ -n "$error_summary" ]; then | |
| echo "::notice::Manual Strix status publish using ${token_label} did not succeed: ${error_summary}" | |
| else | |
| echo "::notice::Manual Strix status publish using ${token_label} did not succeed." | |
| fi | |
| return 1 | |
| } | |
| existing_current_run_success_status() { | |
| if [ "$state" != "success" ]; then | |
| return 1 | |
| fi | |
| target_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" | |
| check_existing_status() { | |
| token_label="$1" | |
| token="$2" | |
| if [ -z "$token" ]; then | |
| return 1 | |
| fi | |
| status_response="$(mktemp)" | |
| status_error="$(mktemp)" | |
| if GH_TOKEN="$token" gh api "repos/${TARGET_REPOSITORY}/commits/${PR_HEAD_SHA}/statuses" \ | |
| >"$status_response" 2>"$status_error"; then | |
| if jq -e --arg target_url "$target_url" \ | |
| 'any(.[]; .context == "strix" and .state == "success" and ((.target_url // "") == $target_url))' \ | |
| "$status_response" >/dev/null; then | |
| rm -f "$status_response" "$status_error" | |
| echo "Existing current-run Strix success status is already present on ${TARGET_REPOSITORY}@${PR_HEAD_SHA}; follow-up status publication is complete." | |
| return 0 | |
| fi | |
| rm -f "$status_response" "$status_error" | |
| echo "::notice::No current-run Strix success status was visible using ${token_label}." | |
| return 1 | |
| fi | |
| error_summary="$(head -n 1 "$status_error" | tr -d '\r' || true)" | |
| rm -f "$status_response" "$status_error" | |
| if [ -n "$error_summary" ]; then | |
| echo "::notice::Could not inspect existing Strix status using ${token_label}: ${error_summary}" | |
| else | |
| echo "::notice::Could not inspect existing Strix status using ${token_label}." | |
| fi | |
| return 1 | |
| } | |
| if check_existing_status "target-app-token" "$TARGET_APP_STATUS_TOKEN"; then | |
| return 0 | |
| fi | |
| if check_existing_status "pr-review-merge-token" "$PR_REVIEW_MERGE_STATUS_TOKEN"; then | |
| return 0 | |
| fi | |
| if check_existing_status "opencode-approve-token" "$OPENCODE_APPROVE_STATUS_TOKEN"; then | |
| return 0 | |
| fi | |
| if check_existing_status "github-token" "$GITHUB_STATUS_READ_TOKEN"; then | |
| return 0 | |
| fi | |
| return 1 | |
| } | |
| if post_strix_status "target-app-token" "$TARGET_APP_STATUS_TOKEN"; then | |
| exit 0 | |
| fi | |
| if post_strix_status "pr-review-merge-token" "$PR_REVIEW_MERGE_STATUS_TOKEN"; then | |
| exit 0 | |
| fi | |
| if post_strix_status "opencode-approve-token" "$OPENCODE_APPROVE_STATUS_TOKEN"; then | |
| exit 0 | |
| fi | |
| if existing_current_run_success_status; then | |
| exit 0 | |
| fi | |
| # A successful scan remains authoritative evidence even when an | |
| # external target repository does not grant any configured token | |
| # permission to create commit statuses. Keep every credential- | |
| # specific failure visible above, but do not turn a clean security | |
| # scan into a failed workflow solely because of target settings. | |
| if [ "$STRIX_RESULT" = "success" ]; then | |
| echo "::warning title=Manual Strix status unavailable::Strix scan succeeded, but no configured credential could publish or read the target commit status. Preserving the successful scan result; the target repository's branch protection remains authoritative. See the preceding token-specific notices." | |
| exit 0 | |
| fi | |
| echo "::error::Could not publish manual Strix status from follow-up job after all configured credentials failed after a non-successful scan; the target PR head is missing required Strix status evidence. See the preceding notices for token-specific reasons." | |
| exit 1 |