Skip to content

Commit 00ddf89

Browse files
hyperpolymathclaude
andcommitted
feat(scripts): fix-actions-policy.sh — repair the estate Actions-policy outage
Implements the "fastest shippable" item of gitbot-fleet#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 — looks like "CI is broken", is actually a settings fault, and no amount of workflow editing fixes it. MEASURED 2026-07-21: 80 of 306 scanned hyperpolymath repos are in this state. ## Why this fixer is shaped unlike the others Every other fix-*.sh takes a REPO_PATH and edits files, so its blast radius is a reviewable diff. This one writes repository *settings* via the API: no diff, no branch, no PR. Per #362 that demands gating, so: * default mode is --list (READ ONLY; never writes) * --apply is required to write * HYPATIA_AUTOMATION={off,disabled,0} halts writes (exit 4) * the estate exclusion registry is consulted FAIL-CLOSED before any write (unreadable registry => refuse to write, exit 5) * every PUT is GET-verified and the sweep ABORTS (exit 3) if sha_pinning was silently reset — never trade pinning away for permissiveness ## Dual invocation contract dispatch-runner.sh calls fixers as `<script> <repo_path> <finding.json>`, but a standalone sweep is `<script> <owner> --apply`. The script detects a directory in $1 and derives owner/repo from the git origin, so one tool serves both paths instead of silently misreading a filesystem path as an owner. In the dispatcher path it stays report-only unless FIX_ACTIONS_POLICY_APPLY=1 — a credentialed diff-less write must not auto-fire just because a finding routed to it. Registered under by_category ActionsPolicyTooRestrictive and by_recipe recipe-actions-allow-all so dispatch-runner auto-routes hypatia findings. ## Verification shellcheck clean; bash -n clean; registry re-parsed with jq. Guard rails, exit codes measured directly (not through a masking pipeline): no owner / bad --mode / unknown flag -> 2 HYPATIA_AUTOMATION=off with --apply -> 4 HYPATIA_AUTOMATION=off with --list -> 0 (read-only unaffected) Classification, against an independently-collected 306-repo ground truth: typefix-zero, stateful-artefacts, trope-checker -> BROKEN-M1 (correct) svalinn, thejeffparadox -> ok (correct) 5/5 agreement. Dispatcher path exercised exactly as dispatch-runner invokes it: fix-actions-policy.sh <path-to-trope-checker> <finding.json> -> "dispatcher mode — hyperpolymath/trope-checker (action=list)" -> BROKEN-M1, exit 0. NOT run with --apply anywhere: widening allowed_actions is a security-posture change across ~80 repos and needs the owner's explicit sign-off first. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 883e614 commit 00ddf89

2 files changed

Lines changed: 235 additions & 1 deletion

File tree

scripts/fix-actions-policy.sh

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
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 (decision of record, #362) ────────────────────────────
33+
# allowed_actions = all + sha_pinning_required = true
34+
# Rationale: an empty allowlist protects nothing — it blocks everything —
35+
# while a pinned SHA cannot be moved under you. `--mode selected` remains
36+
# available for a high-sensitivity tier, populating patterns_allowed from
37+
# hyperpolymath/standards rather than leaving it empty.
38+
# NEVER disable sha_pinning_required.
39+
#
40+
# Idempotent: healthy repos are reported `ok` and skipped.
41+
#
42+
# Usage:
43+
# fix-actions-policy.sh <owner> [--list|--apply] [--mode all|selected]
44+
# [--limit N] [--jsonl FILE] [--repo NAME]
45+
#
46+
# Exit codes: 0 ok · 2 usage · 3 sha_pinning reset (ABORT) · 4 kill switch
47+
# 5 exclusion registry unavailable while applying
48+
49+
set -euo pipefail
50+
51+
OWNER=""
52+
ACTION="list" # list | apply (report-by-default)
53+
MODE="all" # all | selected
54+
LIMIT=500
55+
JSONL=""
56+
ONE_REPO=""
57+
FINDING_FILE="" # dispatch-runner contract: $2 is the finding JSON
58+
STD_REPO="hyperpolymath/standards"
59+
60+
die() { echo "fix-actions-policy: $*" >&2; exit "${2:-2}"; }
61+
62+
while [ $# -gt 0 ]; do
63+
case "$1" in
64+
--list) ACTION="list" ;;
65+
--apply) ACTION="apply" ;;
66+
--mode) MODE="${2:-}"; shift ;;
67+
--limit) LIMIT="${2:-}"; shift ;;
68+
--jsonl) JSONL="${2:-}"; shift ;;
69+
--repo) ONE_REPO="${2:-}"; shift ;;
70+
-h|--help) sed -n '2,50p' "$0"; exit 0 ;;
71+
-*) die "unknown flag: $1" ;;
72+
*) if [ -z "$OWNER" ]; then OWNER="$1"
73+
elif [ -z "$FINDING_FILE" ]; then FINDING_FILE="$1" # dispatch-runner passes finding JSON as $2
74+
else die "unexpected arg: $1"; fi ;;
75+
esac
76+
shift
77+
done
78+
79+
# ── Dual invocation contract ─────────────────────────────────────────────
80+
# scripts/dispatch-runner.sh calls every fixer as `<script> <repo_path>
81+
# <finding.json>` — a filesystem path, not an owner. Standalone sweeps
82+
# (#362) call it as `<script> <owner> --apply`. Detect which we got, so the
83+
# same tool serves the fleet AND the 80-repo sweep instead of silently
84+
# misreading a path as an owner.
85+
if [ -n "$OWNER" ] && [ -d "$OWNER" ]; then
86+
REPO_PATH="$OWNER"
87+
ORIGIN="$(git -C "$REPO_PATH" remote get-url origin 2>/dev/null || true)"
88+
[ -n "$ORIGIN" ] || die "dispatcher mode: $REPO_PATH has no git origin"
89+
SLUG="$(printf '%s' "$ORIGIN" | sed -E 's#(git@|https://)github\.com[:/]##; s#\.git$##')"
90+
OWNER="${SLUG%%/*}"
91+
ONE_REPO="${SLUG#*/}"
92+
[ -n "$OWNER" ] && [ -n "$ONE_REPO" ] || die "dispatcher mode: cannot parse owner/repo from '$ORIGIN'"
93+
# Report-by-default in the dispatcher path. Per #362 this fixer performs a
94+
# credentialed, diff-less settings write with no PR to review, so it must
95+
# NOT auto-apply just because a finding routed to it. Opt in explicitly.
96+
case "${FIX_ACTIONS_POLICY_APPLY:-}" in
97+
1|true|yes) ACTION="apply" ;;
98+
*) ACTION="list" ;;
99+
esac
100+
echo "fix-actions-policy: dispatcher mode — ${OWNER}/${ONE_REPO} (action=${ACTION})"
101+
fi
102+
103+
[ -n "$OWNER" ] || die "usage: fix-actions-policy.sh <owner|repo_path> [--list|--apply] [--mode all|selected]"
104+
case "$MODE" in all|selected) ;; *) die "--mode must be 'all' or 'selected'" ;; esac
105+
command -v gh >/dev/null || die "gh CLI not found"
106+
command -v jq >/dev/null || die "jq not found"
107+
108+
# ── Guard rails (writes only) ────────────────────────────────────────────
109+
if [ "$ACTION" = "apply" ]; then
110+
case "${HYPATIA_AUTOMATION:-}" in
111+
off|disabled|0)
112+
die "HYPATIA_AUTOMATION=${HYPATIA_AUTOMATION} — global kill switch engaged" 4 ;;
113+
esac
114+
115+
# Fail-closed: if the estate denylist cannot be read, do not write.
116+
EXCL_RAW="$(gh api "repos/${STD_REPO}/contents/.machine_readable/bot_exclusion_registry.a2ml" \
117+
--jq '.content' 2>/dev/null | base64 -d 2>/dev/null || true)"
118+
if [ -z "$EXCL_RAW" ]; then
119+
die "exclusion registry unreadable (${STD_REPO}/.machine_readable/bot_exclusion_registry.a2ml) — refusing to write" 5
120+
fi
121+
# external-repos axis: exact owner/repo matches that must never be touched.
122+
EXCLUDED="$(printf '%s' "$EXCL_RAW" | grep -oE '"[A-Za-z0-9._-]+/[A-Za-z0-9._-]+"' | tr -d '"' | sort -u || true)"
123+
fi
124+
125+
is_excluded() {
126+
[ -n "${EXCLUDED:-}" ] || return 1
127+
printf '%s\n' "$EXCLUDED" | grep -qxF "$1"
128+
}
129+
130+
emit() { [ -n "$JSONL" ] && printf '%s\n' "$1" >> "$JSONL"; return 0; }
131+
132+
# ── Account shape decides the lever ──────────────────────────────────────
133+
OWNER_TYPE="$(gh api "users/${OWNER}" --jq '.type' 2>/dev/null || echo Unknown)"
134+
135+
if [ "$OWNER_TYPE" = "Organization" ] && [ -z "$ONE_REPO" ]; then
136+
echo "== ${OWNER} is an Organization — a single org-level PUT covers every repo by inheritance."
137+
if ORG="$(gh api "orgs/${OWNER}/actions/permissions" 2>/dev/null)"; then
138+
echo " current: allowed_actions=$(echo "$ORG" | jq -r '.allowed_actions // "-"')"
139+
if [ "$ACTION" = "apply" ]; then
140+
gh api --method PUT "orgs/${OWNER}/actions/permissions" \
141+
-f enabled_repositories=all -f allowed_actions="$MODE" >/dev/null
142+
echo " applied: allowed_actions=${MODE} (org level)"
143+
else
144+
echo " --list only; re-run with --apply to set allowed_actions=${MODE}"
145+
fi
146+
else
147+
echo " !! cannot read org Actions policy — the token lacks the 'admin:org' scope."
148+
echo " Run: gh auth refresh -h github.com -s admin:org"
149+
echo " Falling back to a per-repo sweep below."
150+
fi
151+
fi
152+
153+
# ── Per-repo sweep ───────────────────────────────────────────────────────
154+
if [ -n "$ONE_REPO" ]; then
155+
REPOS="$ONE_REPO"
156+
else
157+
REPOS="$(gh repo list "$OWNER" --no-archived --source --limit "$LIMIT" --json name -q '.[].name')"
158+
fi
159+
160+
TOTAL=0; BROKEN=0; FIXED=0; SKIPPED=0
161+
printf '%-44s %-9s %-9s %-8s %s\n' REPO ALLOWED SHA_PIN PATTERNS STATUS
162+
163+
for R in $REPOS; do
164+
TOTAL=$((TOTAL+1))
165+
FULL="${OWNER}/${R}"
166+
167+
P="$(gh api "repos/${FULL}/actions/permissions" 2>/dev/null || true)"
168+
if [ -z "$P" ]; then
169+
printf '%-44s %-9s %-9s %-8s %s\n' "$R" - - - "no-access"
170+
emit "{\"repo\":\"${FULL}\",\"status\":\"no-access\"}"
171+
continue
172+
fi
173+
174+
AL="$(echo "$P" | jq -r '.allowed_actions // "-"')"
175+
SP="$(echo "$P" | jq -r '.sha_pinning_required // "-"')"
176+
N="-"
177+
if [ "$AL" = "selected" ]; then
178+
N="$(gh api "repos/${FULL}/actions/permissions/selected-actions" --jq '.patterns_allowed|length' 2>/dev/null || echo 0)"
179+
fi
180+
181+
# BROKEN-M1: selected with an empty allowlist == every non-github action rejected.
182+
if [ "$AL" = "selected" ] && [ "$N" = "0" ]; then
183+
BROKEN=$((BROKEN+1)); STATUS="BROKEN-M1"
184+
else
185+
STATUS="ok"
186+
fi
187+
188+
if [ "$ACTION" != "apply" ] || [ "$STATUS" != "BROKEN-M1" ]; then
189+
printf '%-44s %-9s %-9s %-8s %s\n' "$R" "$AL" "$SP" "$N" "$STATUS"
190+
emit "{\"repo\":\"${FULL}\",\"allowed_actions\":\"${AL}\",\"sha_pinning_required\":\"${SP}\",\"patterns\":\"${N}\",\"status\":\"${STATUS}\"}"
191+
continue
192+
fi
193+
194+
if is_excluded "$FULL"; then
195+
SKIPPED=$((SKIPPED+1))
196+
printf '%-44s %-9s %-9s %-8s %s\n' "$R" "$AL" "$SP" "$N" "excluded"
197+
emit "{\"repo\":\"${FULL}\",\"status\":\"excluded\"}"
198+
continue
199+
fi
200+
201+
if [ "$MODE" = "all" ]; then
202+
gh api --method PUT "repos/${FULL}/actions/permissions" \
203+
-f enabled=true -f allowed_actions=all -F sha_pinning_required=true >/dev/null
204+
else
205+
gh api "repos/${STD_REPO}/actions/permissions/selected-actions" > /tmp/allow.$$.json 2>/dev/null \
206+
|| die "cannot read the standards allowlist to seed --mode selected"
207+
gh api --method PUT "repos/${FULL}/actions/permissions/selected-actions" \
208+
--input /tmp/allow.$$.json >/dev/null
209+
rm -f /tmp/allow.$$.json
210+
fi
211+
212+
# GET-verify. Trading sha_pinning away for permissiveness is never acceptable,
213+
# so a silent reset aborts the whole sweep rather than continuing.
214+
V="$(gh api "repos/${FULL}/actions/permissions" 2>/dev/null || true)"
215+
VSP="$(echo "$V" | jq -r '.sha_pinning_required // "-"')"
216+
VAL="$(echo "$V" | jq -r '.allowed_actions // "-"')"
217+
if [ "$SP" = "true" ] && [ "$VSP" != "true" ]; then
218+
printf '%-44s %-9s %-9s %-8s %s\n' "$R" "$VAL" "$VSP" "-" "ABORT-PIN-RESET"
219+
emit "{\"repo\":\"${FULL}\",\"status\":\"abort-sha-pinning-reset\"}"
220+
die "sha_pinning_required was reset to '${VSP}' on ${FULL} — aborting sweep" 3
221+
fi
222+
223+
FIXED=$((FIXED+1))
224+
printf '%-44s %-9s %-9s %-8s %s\n' "$R" "$VAL" "$VSP" "-" "FIXED"
225+
emit "{\"repo\":\"${FULL}\",\"allowed_actions\":\"${VAL}\",\"sha_pinning_required\":\"${VSP}\",\"status\":\"fixed\"}"
226+
done
227+
228+
echo
229+
echo "scanned=${TOTAL} broken=${BROKEN} fixed=${FIXED} excluded=${SKIPPED} mode=${ACTION}/${MODE}"
230+
if [ "$ACTION" != "apply" ] && [ "$BROKEN" -gt 0 ]; then
231+
echo "re-run with --apply to repair the ${BROKEN} BROKEN-M1 repo(s)."
232+
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)