Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .github/workflows/strix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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) ||
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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:
Expand Down
51 changes: 51 additions & 0 deletions scripts/ci/emit_opencode_failed_check_fallback_findings.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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"
Expand Down
159 changes: 121 additions & 38 deletions scripts/ci/opencode_review_approve_gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand Down
16 changes: 13 additions & 3 deletions scripts/ci/strix_required_workflow_smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
Loading
Loading