Skip to content

Commit a3e9d4b

Browse files
feat(scripts): fix-actions-policy.sh — repair the estate Actions-policy outage (#362) (#434)
Implements the "fastest shippable" item of #362. ## The fault An onboarding stamp set `allowed_actions=selected` + `sha_pinning_required=true` but never populated `patterns_allowed`, leaving it `[]`. **An empty selected-list rejects every non-github-owned `uses:` at workflow-parse time**, so runs die as `startup_failure` with zero jobs. It presents as "CI is broken" but no workflow edit can fix it — it is a settings fault. **Measured 2026-07-21: 80 of 306 scanned `hyperpolymath` repos are in this state**, independently corroborating #362's estimate of 91. `hyperpolymath` is a **User** account so there is no org lever; `metadatastician` is an **Organization** where one org-level PUT covers every repo (needs `admin:org`). ## Why this fixer is shaped unlike every other `fix-*.sh` The others take a `REPO_PATH` and edit files — blast radius is a reviewable diff. This one writes repository **settings** through the API: no diff, no branch, no PR. #362 calls this out explicitly, so it is gated: | Guard | Behaviour | |---|---| | Default mode | `--list` — **read-only**, never writes | | Writes | require explicit `--apply` | | Kill switch | `HYPATIA_AUTOMATION={off,disabled,0}` → exit 4 | | Exclusion registry | consulted **fail-closed**; unreadable → refuse to write, exit 5 | | Post-write check | every PUT is GET-verified; a silent `sha_pinning` reset **aborts** (exit 3) | `sha_pinning_required` is never disabled. Under `--mode all` the remaining control is mandatory SHA-pinning — which is the point: a pinned SHA cannot be moved under you, whereas an *empty* allowlist protects nothing and blocks everything. `--mode selected` remains for a high-sensitivity tier, seeding `patterns_allowed` from `standards`. ## Dual invocation contract (integration bug caught before it shipped) `dispatch-runner.sh` calls fixers as `<script> <repo_path> <finding.json>`, but a standalone sweep is `<script> <owner> --apply`. Registering the script without handling that would have made the fleet pass a **filesystem path where an owner was expected**. The script now detects a directory in `$1` and derives owner/repo from the git origin, so one tool serves both. In the dispatcher path it stays **report-only** unless `FIX_ACTIONS_POLICY_APPLY=1` — a credentialed, diff-less write must not auto-fire merely because a finding routed to it. Registered under `by_category: ActionsPolicyTooRestrictive` and `by_recipe: recipe-actions-allow-all`. ## Verification `shellcheck` clean · `bash -n` clean · registry re-parsed with `jq`. **Guard rails** (exit codes measured directly, not through a masking pipeline): | Invocation | Exit | Expected | |---|---|---| | no owner / bad `--mode` / unknown flag | 2 | ✅ | | `HYPATIA_AUTOMATION=off` + `--apply` | 4 | ✅ | | `HYPATIA_AUTOMATION=off` + `--list` | 0 | ✅ read-only unaffected | **Classification** vs an independently collected 306-repo ground truth — **5/5 agreement**: | Repo | Tool | Truth | |---|---|---| | typefix-zero, stateful-artefacts, trope-checker | `BROKEN-M1` | `BROKEN-M1` ✅ | | svalinn, thejeffparadox | `ok` | `ok` ✅ | **Dispatcher path**, invoked exactly as `dispatch-runner.sh` does: ``` $ fix-actions-policy.sh /…/hyper-repos/trope-checker /tmp/dispatch-finding-X.json fix-actions-policy: dispatcher mode — hyperpolymath/trope-checker (action=list) trope-checker selected true 0 BROKEN-M1 scanned=1 broken=1 fixed=0 excluded=0 mode=list/all # exit 0 ``` ## ⚠️ Not applied anywhere `--apply` has **not** been run. Widening `allowed_actions` across ~80 repos is a security-posture change and needs explicit owner sign-off. This PR ships the tool and the evidence; the sweep is a separate, deliberate decision. Suggested rollout once signed off: `--apply --repo <one>` → confirm its workflows actually start → 3-repo pilot → full sweep → re-run `--list` to confirm `broken=0`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 883e614 commit a3e9d4b

2 files changed

Lines changed: 261 additions & 1 deletion

File tree

scripts/fix-actions-policy.sh

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
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

scripts/fix-script-registry.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,11 @@
4646
"MissingFeedbackIntegration": "fix-missing-feedback-integration.sh",
4747
"MissingVexometerHooks": "fix-missing-vexometer-hooks.sh",
4848
"TrackedNpmLockfile": "fix-tracked-package-lock.sh",
49-
"LicensePMPLDrift": "fix-pmpl-drift.sh"
49+
"LicensePMPLDrift": "fix-pmpl-drift.sh",
50+
"ActionsPolicyTooRestrictive": "fix-actions-policy.sh"
5051
},
5152
"by_recipe": {
53+
"recipe-actions-allow-all": "fix-actions-policy.sh",
5254
"recipe-heredoc-to-install": "fix-heredoc-install.sh",
5355
"recipe-unwrap-to-match": "fix-unwrap-to-match.sh",
5456
"recipe-innerhtml-to-textcontent": "fix-innerhtml.sh",

0 commit comments

Comments
 (0)