|
| 1 | +#!/bin/bash |
| 2 | +# SPDX-License-Identifier: MPL-2.0 |
| 3 | +# |
| 4 | +# fix-actions-policy.sh — repair the estate-wide "Actions-policy CI outage" |
| 5 | +# |
| 6 | +# Driven by gitbot-fleet#362. An onboarding stamp set |
| 7 | +# `allowed_actions=selected` + `sha_pinning_required=true` but never |
| 8 | +# populated `patterns_allowed`, leaving it `[]`. An empty selected-list |
| 9 | +# rejects every non-github-owned `uses:` at workflow-parse time, so the run |
| 10 | +# dies as `startup_failure` with ZERO jobs — indistinguishable from "CI is |
| 11 | +# broken" but actually a settings fault. |
| 12 | +# |
| 13 | +# MEASURED 2026-07-21: 80 of 306 scanned hyperpolymath repos are in this |
| 14 | +# state (BROKEN-M1). `hyperpolymath` is a USER account, so there is no org |
| 15 | +# lever and each repo needs its own settings PUT. `metadatastician` IS an |
| 16 | +# Organization, where a single org-level PUT fixes every repo by |
| 17 | +# inheritance (requires the `admin:org` token scope). |
| 18 | +# |
| 19 | +# ── Why this fixer is shaped differently to every other fix-*.sh ────────── |
| 20 | +# Every other script in this directory takes a REPO_PATH and edits files, so |
| 21 | +# its blast radius is a diff that a human reviews in a PR. This one writes |
| 22 | +# repository *settings* through the API: there is no diff, no branch and no |
| 23 | +# PR. Per #362 that means it must be report-by-default and gated. Hence: |
| 24 | +# |
| 25 | +# * default mode is --list (READ ONLY — never writes) |
| 26 | +# * --apply is required to write, and is refused without an explicit mode |
| 27 | +# * the HYPATIA_AUTOMATION kill switch halts writes instantly |
| 28 | +# * the estate exclusion registry is consulted, FAIL-CLOSED, before writes |
| 29 | +# * every PUT is GET-verified, and the sweep ABORTS if sha_pinning was |
| 30 | +# silently reset (never trade pinning away for permissiveness) |
| 31 | +# |
| 32 | +# ── Target posture ─────────────────────────────────────────────────────── |
| 33 | +# #362's decision of record was `allowed_actions=all` + sha_pinning. A |
| 34 | +# parallel remediation (llm-coding-configs/claude-code/notification-storm- |
| 35 | +# remediation/FINDINGS.md, 2026-07-21) established a STRICTLY SAFER route |
| 36 | +# that fixes the same 98 repos WITHOUT widening anything: |
| 37 | +# |
| 38 | +# keep allowed_actions=selected, keep sha_pinning_required=true, |
| 39 | +# and simply POPULATE the empty patterns_allowed list. |
| 40 | +# |
| 41 | +# Its evidence is a natural experiment over 426 repos: |
| 42 | +# allowed_actions=all 286 repos -> wrappers run |
| 43 | +# selected + `hyperpolymath/*` present 42 repos -> wrappers run |
| 44 | +# selected + patterns_allowed=[] 98 repos -> ALL startup_failure |
| 45 | +# i.e. the breakage is caused by the list being EMPTY, not by it existing. |
| 46 | +# Populating it therefore restores CI while KEEPING the owner allowlist as a |
| 47 | +# real control — so `selected` is the default here, and `--mode all` is the |
| 48 | +# opt-in fallback rather than the target. |
| 49 | +# |
| 50 | +# NEVER disable sha_pinning_required. |
| 51 | +# |
| 52 | +# Idempotent: healthy repos are reported `ok` and skipped. |
| 53 | +# |
| 54 | +# Usage: |
| 55 | +# fix-actions-policy.sh <owner> [--list|--apply] [--mode all|selected] |
| 56 | +# [--limit N] [--jsonl FILE] [--repo NAME] |
| 57 | +# |
| 58 | +# Exit codes: 0 ok · 2 usage · 3 sha_pinning reset (ABORT) · 4 kill switch |
| 59 | +# 5 exclusion registry unavailable while applying |
| 60 | + |
| 61 | +set -euo pipefail |
| 62 | + |
| 63 | +OWNER="" |
| 64 | +ACTION="list" # list | apply (report-by-default) |
| 65 | +MODE="selected" # selected (default, non-widening) | all (opt-in fallback) |
| 66 | +ALLOWLIST="" # curated patterns_allowed JSON; defaults to standards' |
| 67 | +LIMIT=500 |
| 68 | +JSONL="" |
| 69 | +ONE_REPO="" |
| 70 | +FINDING_FILE="" # dispatch-runner contract: $2 is the finding JSON |
| 71 | +STD_REPO="hyperpolymath/standards" |
| 72 | + |
| 73 | +die() { echo "fix-actions-policy: $*" >&2; exit "${2:-2}"; } |
| 74 | + |
| 75 | +while [ $# -gt 0 ]; do |
| 76 | + case "$1" in |
| 77 | + --list) ACTION="list" ;; |
| 78 | + --apply) ACTION="apply" ;; |
| 79 | + --mode) MODE="${2:-}"; shift ;; |
| 80 | + --allowlist) ALLOWLIST="${2:-}"; shift ;; |
| 81 | + --limit) LIMIT="${2:-}"; shift ;; |
| 82 | + --jsonl) JSONL="${2:-}"; shift ;; |
| 83 | + --repo) ONE_REPO="${2:-}"; shift ;; |
| 84 | + -h|--help) sed -n '2,50p' "$0"; exit 0 ;; |
| 85 | + -*) die "unknown flag: $1" ;; |
| 86 | + *) if [ -z "$OWNER" ]; then OWNER="$1" |
| 87 | + elif [ -z "$FINDING_FILE" ]; then FINDING_FILE="$1" # dispatch-runner passes finding JSON as $2 |
| 88 | + else die "unexpected arg: $1"; fi ;; |
| 89 | + esac |
| 90 | + shift |
| 91 | +done |
| 92 | + |
| 93 | +# ── Dual invocation contract ───────────────────────────────────────────── |
| 94 | +# scripts/dispatch-runner.sh calls every fixer as `<script> <repo_path> |
| 95 | +# <finding.json>` — a filesystem path, not an owner. Standalone sweeps |
| 96 | +# (#362) call it as `<script> <owner> --apply`. Detect which we got, so the |
| 97 | +# same tool serves the fleet AND the 80-repo sweep instead of silently |
| 98 | +# misreading a path as an owner. |
| 99 | +if [ -n "$OWNER" ] && [ -d "$OWNER" ]; then |
| 100 | + REPO_PATH="$OWNER" |
| 101 | + ORIGIN="$(git -C "$REPO_PATH" remote get-url origin 2>/dev/null || true)" |
| 102 | + [ -n "$ORIGIN" ] || die "dispatcher mode: $REPO_PATH has no git origin" |
| 103 | + SLUG="$(printf '%s' "$ORIGIN" | sed -E 's#(git@|https://)github\.com[:/]##; s#\.git$##')" |
| 104 | + OWNER="${SLUG%%/*}" |
| 105 | + ONE_REPO="${SLUG#*/}" |
| 106 | + [ -n "$OWNER" ] && [ -n "$ONE_REPO" ] || die "dispatcher mode: cannot parse owner/repo from '$ORIGIN'" |
| 107 | + # Report-by-default in the dispatcher path. Per #362 this fixer performs a |
| 108 | + # credentialed, diff-less settings write with no PR to review, so it must |
| 109 | + # NOT auto-apply just because a finding routed to it. Opt in explicitly. |
| 110 | + case "${FIX_ACTIONS_POLICY_APPLY:-}" in |
| 111 | + 1|true|yes) ACTION="apply" ;; |
| 112 | + *) ACTION="list" ;; |
| 113 | + esac |
| 114 | + echo "fix-actions-policy: dispatcher mode — ${OWNER}/${ONE_REPO} (action=${ACTION})" |
| 115 | +fi |
| 116 | + |
| 117 | +[ -n "$OWNER" ] || die "usage: fix-actions-policy.sh <owner|repo_path> [--list|--apply] [--mode all|selected]" |
| 118 | +case "$MODE" in all|selected) ;; *) die "--mode must be 'all' or 'selected'" ;; esac |
| 119 | +command -v gh >/dev/null || die "gh CLI not found" |
| 120 | +command -v jq >/dev/null || die "jq not found" |
| 121 | + |
| 122 | +# ── Guard rails (writes only) ──────────────────────────────────────────── |
| 123 | +if [ "$ACTION" = "apply" ]; then |
| 124 | + case "${HYPATIA_AUTOMATION:-}" in |
| 125 | + off|disabled|0) |
| 126 | + die "HYPATIA_AUTOMATION=${HYPATIA_AUTOMATION} — global kill switch engaged" 4 ;; |
| 127 | + esac |
| 128 | + |
| 129 | + # Fail-closed: if the estate denylist cannot be read, do not write. |
| 130 | + EXCL_RAW="$(gh api "repos/${STD_REPO}/contents/.machine_readable/bot_exclusion_registry.a2ml" \ |
| 131 | + --jq '.content' 2>/dev/null | base64 -d 2>/dev/null || true)" |
| 132 | + if [ -z "$EXCL_RAW" ]; then |
| 133 | + die "exclusion registry unreadable (${STD_REPO}/.machine_readable/bot_exclusion_registry.a2ml) — refusing to write" 5 |
| 134 | + fi |
| 135 | + # external-repos axis: exact owner/repo matches that must never be touched. |
| 136 | + EXCLUDED="$(printf '%s' "$EXCL_RAW" | grep -oE '"[A-Za-z0-9._-]+/[A-Za-z0-9._-]+"' | tr -d '"' | sort -u || true)" |
| 137 | +fi |
| 138 | + |
| 139 | +is_excluded() { |
| 140 | + [ -n "${EXCLUDED:-}" ] || return 1 |
| 141 | + printf '%s\n' "$EXCLUDED" | grep -qxF "$1" |
| 142 | +} |
| 143 | + |
| 144 | +emit() { [ -n "$JSONL" ] && printf '%s\n' "$1" >> "$JSONL"; return 0; } |
| 145 | + |
| 146 | +# ── Account shape decides the lever ────────────────────────────────────── |
| 147 | +OWNER_TYPE="$(gh api "users/${OWNER}" --jq '.type' 2>/dev/null || echo Unknown)" |
| 148 | + |
| 149 | +if [ "$OWNER_TYPE" = "Organization" ] && [ -z "$ONE_REPO" ]; then |
| 150 | + echo "== ${OWNER} is an Organization — a single org-level PUT covers every repo by inheritance." |
| 151 | + if ORG="$(gh api "orgs/${OWNER}/actions/permissions" 2>/dev/null)"; then |
| 152 | + echo " current: allowed_actions=$(echo "$ORG" | jq -r '.allowed_actions // "-"')" |
| 153 | + if [ "$ACTION" = "apply" ]; then |
| 154 | + gh api --method PUT "orgs/${OWNER}/actions/permissions" \ |
| 155 | + -f enabled_repositories=all -f allowed_actions="$MODE" >/dev/null |
| 156 | + echo " applied: allowed_actions=${MODE} (org level)" |
| 157 | + else |
| 158 | + echo " --list only; re-run with --apply to set allowed_actions=${MODE}" |
| 159 | + fi |
| 160 | + else |
| 161 | + echo " !! cannot read org Actions policy — the token lacks the 'admin:org' scope." |
| 162 | + echo " Run: gh auth refresh -h github.com -s admin:org" |
| 163 | + echo " Falling back to a per-repo sweep below." |
| 164 | + fi |
| 165 | +fi |
| 166 | + |
| 167 | +# ── Per-repo sweep ─────────────────────────────────────────────────────── |
| 168 | +if [ -n "$ONE_REPO" ]; then |
| 169 | + REPOS="$ONE_REPO" |
| 170 | +else |
| 171 | + REPOS="$(gh repo list "$OWNER" --no-archived --source --limit "$LIMIT" --json name -q '.[].name')" |
| 172 | +fi |
| 173 | + |
| 174 | +TOTAL=0; BROKEN=0; FIXED=0; SKIPPED=0 |
| 175 | +printf '%-44s %-9s %-9s %-8s %s\n' REPO ALLOWED SHA_PIN PATTERNS STATUS |
| 176 | + |
| 177 | +for R in $REPOS; do |
| 178 | + TOTAL=$((TOTAL+1)) |
| 179 | + FULL="${OWNER}/${R}" |
| 180 | + |
| 181 | + P="$(gh api "repos/${FULL}/actions/permissions" 2>/dev/null || true)" |
| 182 | + if [ -z "$P" ]; then |
| 183 | + printf '%-44s %-9s %-9s %-8s %s\n' "$R" - - - "no-access" |
| 184 | + emit "{\"repo\":\"${FULL}\",\"status\":\"no-access\"}" |
| 185 | + continue |
| 186 | + fi |
| 187 | + |
| 188 | + AL="$(echo "$P" | jq -r '.allowed_actions // "-"')" |
| 189 | + SP="$(echo "$P" | jq -r '.sha_pinning_required // "-"')" |
| 190 | + N="-" |
| 191 | + if [ "$AL" = "selected" ]; then |
| 192 | + N="$(gh api "repos/${FULL}/actions/permissions/selected-actions" --jq '.patterns_allowed|length' 2>/dev/null || echo 0)" |
| 193 | + fi |
| 194 | + |
| 195 | + # BROKEN-M1: selected with an empty allowlist == every non-github action rejected. |
| 196 | + if [ "$AL" = "selected" ] && [ "$N" = "0" ]; then |
| 197 | + BROKEN=$((BROKEN+1)); STATUS="BROKEN-M1" |
| 198 | + else |
| 199 | + STATUS="ok" |
| 200 | + fi |
| 201 | + |
| 202 | + if [ "$ACTION" != "apply" ] || [ "$STATUS" != "BROKEN-M1" ]; then |
| 203 | + printf '%-44s %-9s %-9s %-8s %s\n' "$R" "$AL" "$SP" "$N" "$STATUS" |
| 204 | + emit "{\"repo\":\"${FULL}\",\"allowed_actions\":\"${AL}\",\"sha_pinning_required\":\"${SP}\",\"patterns\":\"${N}\",\"status\":\"${STATUS}\"}" |
| 205 | + continue |
| 206 | + fi |
| 207 | + |
| 208 | + if is_excluded "$FULL"; then |
| 209 | + SKIPPED=$((SKIPPED+1)) |
| 210 | + printf '%-44s %-9s %-9s %-8s %s\n' "$R" "$AL" "$SP" "$N" "excluded" |
| 211 | + emit "{\"repo\":\"${FULL}\",\"status\":\"excluded\"}" |
| 212 | + continue |
| 213 | + fi |
| 214 | + |
| 215 | + if [ "$MODE" = "all" ]; then |
| 216 | + gh api --method PUT "repos/${FULL}/actions/permissions" \ |
| 217 | + -f enabled=true -f allowed_actions=all -F sha_pinning_required=true >/dev/null |
| 218 | + else |
| 219 | + # Non-widening path: leave allowed_actions/sha_pinning untouched and only |
| 220 | + # populate the empty patterns_allowed. Prefer an explicitly curated list |
| 221 | + # (--allowlist); otherwise mirror hyperpolymath/standards, which is itself |
| 222 | + # a healthy `selected` repo. |
| 223 | + SRC="$ALLOWLIST" |
| 224 | + if [ -z "$SRC" ]; then |
| 225 | + gh api "repos/${STD_REPO}/actions/permissions/selected-actions" > "/tmp/allow.$$.json" 2>/dev/null \ |
| 226 | + || die "cannot read the standards allowlist; pass --allowlist FILE instead" |
| 227 | + SRC="/tmp/allow.$$.json" |
| 228 | + fi |
| 229 | + [ -s "$SRC" ] || die "allowlist '$SRC' is missing or empty" |
| 230 | + jq -e '(.patterns_allowed|length) > 0' "$SRC" >/dev/null 2>&1 \ |
| 231 | + || die "allowlist '$SRC' has an EMPTY patterns_allowed — that is the very fault being fixed" |
| 232 | + gh api --method PUT "repos/${FULL}/actions/permissions/selected-actions" --input "$SRC" >/dev/null |
| 233 | + # `&& rm` alone would return 1 when the test is false and, under `set -e`, |
| 234 | + # abort the sweep on the --allowlist path. Keep the guard total. |
| 235 | + if [ "$SRC" = "/tmp/allow.$$.json" ]; then rm -f "/tmp/allow.$$.json"; fi |
| 236 | + fi |
| 237 | + |
| 238 | + # GET-verify. Trading sha_pinning away for permissiveness is never acceptable, |
| 239 | + # so a silent reset aborts the whole sweep rather than continuing. |
| 240 | + V="$(gh api "repos/${FULL}/actions/permissions" 2>/dev/null || true)" |
| 241 | + VSP="$(echo "$V" | jq -r '.sha_pinning_required // "-"')" |
| 242 | + VAL="$(echo "$V" | jq -r '.allowed_actions // "-"')" |
| 243 | + if [ "$SP" = "true" ] && [ "$VSP" != "true" ]; then |
| 244 | + printf '%-44s %-9s %-9s %-8s %s\n' "$R" "$VAL" "$VSP" "-" "ABORT-PIN-RESET" |
| 245 | + emit "{\"repo\":\"${FULL}\",\"status\":\"abort-sha-pinning-reset\"}" |
| 246 | + die "sha_pinning_required was reset to '${VSP}' on ${FULL} — aborting sweep" 3 |
| 247 | + fi |
| 248 | + |
| 249 | + FIXED=$((FIXED+1)) |
| 250 | + printf '%-44s %-9s %-9s %-8s %s\n' "$R" "$VAL" "$VSP" "-" "FIXED" |
| 251 | + emit "{\"repo\":\"${FULL}\",\"allowed_actions\":\"${VAL}\",\"sha_pinning_required\":\"${VSP}\",\"status\":\"fixed\"}" |
| 252 | +done |
| 253 | + |
| 254 | +echo |
| 255 | +echo "scanned=${TOTAL} broken=${BROKEN} fixed=${FIXED} excluded=${SKIPPED} mode=${ACTION}/${MODE}" |
| 256 | +if [ "$ACTION" != "apply" ] && [ "$BROKEN" -gt 0 ]; then |
| 257 | + echo "re-run with --apply to repair the ${BROKEN} BROKEN-M1 repo(s)." |
| 258 | +fi |
0 commit comments