Skip to content

Commit 9a36c90

Browse files
hyperpolymathclaude
andcommitted
ci: allowlist-gap resilience — canonical allowed-actions + detect/apply tooling
Fixes the un-squabble-able `startup_failure` class: under `allowed_actions: selected`, a workflow that `uses:` an un-allowlisted action/reusable dies at startup with zero jobs and no log. Migrating a repo between orgs resets `patterns_allowed` to [] and blocks EVERY estate action at once (observed 2026-07-11: gossamer -> metadatastician, 16/25 `uses:` blocked, mass CI outage). Teaches the three CI/CD tools to deal with it: - SQUABBLER: scripts/check-allowed-actions.sh — turns the opaque startup_failure into a legible, satisfiable gate (lists uncovered `uses:`; exit 1 on gap; no API/admin). Runnable in a preflight job, the governance gate, or rhodibot. - HYPATIA: hypatia-rules/workflow-allowlist-gap.a2ml (HYP-S008) — scans the workflow tree and emits compliance.finding.new for each uncovered `uses:`. - ROBOT-REPO-AUTOMATON: scripts/set-allowed-actions.sh — applies the canonical allowlist (admin PAT); the onboarding/migration sweep MUST run it so `patterns_allowed` is never empty. Rhodibot detects+reports; the admin PAT sets. Canonical source of truth: rhodium-standard-repositories/actions-allowlist/ allowed-actions.json (80 patterns = the estate set + the 4 that were missing: dawidd6/action-send-mail, android-actions/setup-android, reactivecircus/android-emulator-runner, slsa-framework/slsa-github-generator). README documents the RSR requirement (repos MUST have a selected allowlist that supersets their workflows' `uses:`). Verified: check exits 0 on the canonical list, lists all 16 gaps on an empty one. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9167d54 commit 9a36c90

6 files changed

Lines changed: 322 additions & 3 deletions

File tree

hypatia-rules/README.adoc

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
:status: Draft v0.2.0
44
:updated: 2026-04-18
55

6-
Seven Hypatia rules specific to the standards-repo dogfooding loop.
7-
Each rule is defined in A2ML, consumes VeriSimDB octads, and emits
8-
Groove `compliance.finding.new` signals.
6+
Eight Hypatia rules specific to the standards-repo dogfooding loop.
7+
Each rule is defined in A2ML, consumes VeriSimDB octads or the repo file
8+
tree, and emits Groove `compliance.finding.new` signals.
99

1010
== Rules
1111

@@ -18,6 +18,7 @@ Groove `compliance.finding.new` signals.
1818
| HYP-S005 | `crg-overclaim-detector` | Alert when a self-declared CRG grade lacks v2.0 evidence artefacts |
1919
| HYP-S006 | `registry-staleness` | Alert when REGISTRY.a2ml source hashes go stale or a DERIVED doc (TOPOLOGY.md) drifts |
2020
| HYP-S007 | `profile-drift-detector` | Flag a 6a2 file whose content drifts from its declared A2ML `@profile` |
21+
| HYP-S008 | `workflow-allowlist-gap` | Flag a workflow `uses:` an action/reusable not permitted by the repo Actions allowlist (would `startup_failure`) |
2122

2223
The CRG rule pair (S001 + S005) together enforce grade-honesty: S001
2324
catches backwards moves, S005 catches forwards-overshoots. Both read from
@@ -39,6 +40,16 @@ flags any file that no longer satisfies its declared profile definition in
3940
`a2ml/profiles/`. It is FLAG-ONLY (`strategy: review`, `auto_fixable: false`)
4041
and is purely structural — it never emits a licence/SPDX finding.
4142

43+
HYP-S008 closes the CI-resilience loop. Under `allowed_actions: selected` a
44+
workflow that `uses:` an un-allowlisted action/reusable dies at *startup* with no
45+
job and no log — an opaque failure you cannot satisfy a gate to fix. This rule
46+
scans `.github/workflows/` and flags each `uses:` not covered by the canonical
47+
allowlist (`rhodium-standard-repositories/actions-allowlist/allowed-actions.json`)
48+
so the gap is a legible finding, not a dark run. It is FLAG-ONLY
49+
(`auto_fixable: false`) — applying the allowlist needs an admin PAT, not the
50+
workflow `GITHUB_TOKEN`; its recipe runs `scripts/check-allowed-actions.sh` and
51+
the paired `scripts/set-allowed-actions.sh` performs the admin fix.
52+
4253
== Implementation
4354

4455
Rules live as `.a2ml` files in this directory. They are consumed by
@@ -64,6 +75,7 @@ Rules read from:
6475
- `rsr-template-repo/docs/governance/CRG-AUDIT-TEMPLATE.adoc` (HYP-S005 reference)
6576
- `a2ml/profiles/REGISTRY.a2ml` + `a2ml/profiles/*/PROFILE.a2ml` and the 6a2
6677
files under `.machine_readable/` (HYP-S007)
78+
- `.github/workflows/*` and `rhodium-standard-repositories/actions-allowlist/allowed-actions.json` (HYP-S008)
6779

6880
And emit:
6981
- Groove `compliance.finding.new` signals with the rule's ID
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
# HYP-S008 — Workflow Allowlist Gap
3+
# Fires when a `.github/workflows` file `uses:` an action or reusable-workflow
4+
# that the repo's Actions allowlist (allowed_actions=selected) does not permit.
5+
#
6+
# That is the condition that makes a run die at STARTUP with zero jobs and no
7+
# log — the opaque, un-squabble-able failure (RSR-PHILOSOPHY: reach green by
8+
# satisfying the gate, never admin-override). A run that fails before it starts
9+
# has no gate to satisfy; this rule reifies the gate so it can be satisfied.
10+
#
11+
# Root cause seen 2026-07-11: repos migrated between orgs (e.g. gossamer →
12+
# metadatastician) get patterns_allowed reset to [], blocking every estate
13+
# action/reusable at once. See rhodium-standard-repositories/actions-allowlist/.
14+
15+
@rule(version="1.0"):
16+
id: HYP-S008
17+
name: "Workflow allowlist gap"
18+
description: "Detect .github/workflows `uses:` refs not covered by the canonical Actions allowlist (would startup_failure under allowed_actions=selected)"
19+
severity: high
20+
category: CiResilience
21+
auto_fixable: false # setting the allowlist needs an admin token, not the workflow GITHUB_TOKEN
22+
source: standards/hypatia-rules
23+
24+
@parameters:
25+
allowlist: rhodium-standard-repositories/actions-allowlist/allowed-actions.json
26+
@end
27+
28+
@query(engine="vcl"):
29+
workflow_uses(scan="repo-tree", dir=".github/workflows")
30+
|> where("not covered_by_allowlist(ref, ${allowlist})")
31+
|> project("file, ref")
32+
@end
33+
34+
@action:
35+
emit_signal: compliance.finding.new
36+
message_template: "Actions allowlist gap: {file} uses {ref} — not permitted by the repo allowlist; the run will startup_failure. Add the pattern to the allowlist, or run scripts/set-allowed-actions.sh."
37+
recipe: check-allowlist
38+
@end
39+
40+
@recipe(id="check-allowlist"):
41+
description: "List the uncovered `uses:` refs — legible + satisfiable, no admin needed. The paired set-allowed-actions.sh (admin PAT) applies the fix."
42+
command: "bash scripts/check-allowed-actions.sh"
43+
auto_applicable: false
44+
@end
45+
46+
@end
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// SPDX-License-Identifier: CC-BY-SA-4.0
2+
// SPDX-FileCopyrightText: 2025-2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
= RSR Actions Allowlist
4+
:toc: preamble
5+
:icons: font
6+
7+
The canonical GitHub Actions allowlist for the estate, and the tooling that keeps
8+
every repo's live allowlist in sync with it.
9+
10+
== The failure this prevents
11+
12+
RSR repos run with `allowed_actions: selected` + `sha_pinning_required: true`
13+
(a security posture). Under that setting, if a workflow `uses:` an action or
14+
reusable-workflow that is **not on the allowlist**, GitHub kills the run at
15+
*startup* — **zero jobs, no log, an opaque "workflow file issue"**. You cannot
16+
"squabble" a run that never started: there is no gate to satisfy, no log to read.
17+
18+
This bites hardest on **repo creation / org migration**: a migrated repo's
19+
`patterns_allowed` is reset (often to `[]`), so *every* estate action and
20+
reusable-workflow is blocked at once and the whole CI goes dark. Observed
21+
2026-07-11 when `gossamer` moved to the `metadatastician` org: `patterns_allowed`
22+
was empty → 16 of 25 `uses:` refs blocked → mass `startup_failure` across
23+
Governance, Secret Scanner, SPARK, Hypatia, Mirror, Android, and push-email.
24+
25+
== Canonical source of truth
26+
27+
`allowed-actions.json` — the estate's permitted patterns (github-owned + verified +
28+
an explicit `patterns_allowed` list). This is the single list every repo's
29+
`selected-actions` policy must be set from. Add a pattern here when the estate
30+
adopts a new action; the check below then enforces it everywhere.
31+
32+
== The three tools
33+
34+
[cols="1,2,3",options="header"]
35+
|===
36+
| Tool | File | Role
37+
38+
| **Squabbler** (CI gate)
39+
| `scripts/check-allowed-actions.sh`
40+
| Turns the opaque `startup_failure` into a *legible, satisfiable* gate: lists exactly which `uses:` are not covered. No API, no admin — run it in a preflight job, in the governance gate, or locally. Exit 1 on any gap.
41+
42+
| **Hypatia** (scanner)
43+
| `hypatia-rules/workflow-allowlist-gap.a2ml` (HYP-S008)
44+
| Scans the workflow tree and emits a `compliance.finding.new` for each uncovered `uses:` — so the gap is caught at scan time, before a run ever startup-fails.
45+
46+
| **Robot-repo-automaton** (onboarding)
47+
| `scripts/set-allowed-actions.sh`
48+
| The ROOT prevention: applies the canonical allowlist to a repo. The farm/onboarding process MUST run this (with an admin PAT) whenever a repo is created or migrated, so `patterns_allowed` is never empty. Rhodibot (only `contents`/`pull-requests: write`) cannot set Actions policy, so it *detects + reports* via the check; the admin PAT *sets* it.
49+
|===
50+
51+
== RSR requirement
52+
53+
A conforming RSR repo MUST:
54+
55+
. set `allowed_actions = selected` and `sha_pinning_required = true`;
56+
. have a `selected-actions` allowlist that is a superset of what its workflows
57+
`uses:` (verify: `check-allowed-actions.sh` exits 0);
58+
. have that allowlist applied by the onboarding automaton, not hand-curated per repo.
59+
60+
== Applying / fixing a repo
61+
62+
[source,console]
63+
----
64+
# Detect gaps (safe, no admin):
65+
bash scripts/check-allowed-actions.sh rhodium-standard-repositories/actions-allowlist/allowed-actions.json .github/workflows
66+
67+
# Apply the canonical allowlist (needs an admin PAT — farm admin token):
68+
bash scripts/set-allowed-actions.sh <owner>/<repo>
69+
----
70+
71+
Or directly:
72+
73+
[source,console]
74+
----
75+
gh api -X PUT repos/<owner>/<repo>/actions/permissions -F enabled=true -f allowed_actions=selected
76+
gh api -X PUT repos/<owner>/<repo>/actions/permissions/selected-actions \
77+
--input rhodium-standard-repositories/actions-allowlist/allowed-actions.json
78+
----
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
{
2+
"github_owned_allowed": true,
3+
"verified_allowed": true,
4+
"patterns_allowed": [
5+
"8398a7/action-slack@*",
6+
"actions-rust-lang/setup-rust-toolchain@*",
7+
"ad-m/github-push-action@*",
8+
"alire-project/setup-alire@*",
9+
"anchore/scan-action@*",
10+
"android-actions/setup-android@*",
11+
"anthropics/claude-code-action@*",
12+
"aquasecurity/trivy-action@*",
13+
"astral-sh/setup-uv@*",
14+
"awalsh128/cache-apt-pkgs-action@*",
15+
"azure/webapps-deploy@*",
16+
"benchmark-action/github-action-benchmark@*",
17+
"cachix/install-nix-action@*",
18+
"cbrgm/cleanup-stale-branches-action@*",
19+
"codecov/codecov-action@*",
20+
"cometkim/rclone-actions@*",
21+
"DavidAnson/markdownlint-cli2-action@*",
22+
"dawidd6/action-download-artifact@*",
23+
"dawidd6/action-send-mail@*",
24+
"DeLaGuardo/setup-clojure@*",
25+
"denoland/setup-deno@*",
26+
"dependabot/fetch-metadata@*",
27+
"dependency-check/Dependency-Check_Action@*",
28+
"devcontainers/ci@*",
29+
"dlang-community/setup-dlang@*",
30+
"docker/build-push-action@*",
31+
"docker/login-action@*",
32+
"docker/metadata-action@*",
33+
"docker/setup-buildx-action@*",
34+
"docker/setup-qemu-action@*",
35+
"dtolnay/rust-action@*",
36+
"dtolnay/rust-toolchain@*",
37+
"editorconfig-checker/action-editorconfig-checker@*",
38+
"EnricoMi/publish-unit-test-result-action@*",
39+
"erlef/setup-beam@*",
40+
"extractions/setup-just@*",
41+
"gitleaks/gitleaks-action@*",
42+
"google/clusterfuzzlite@*",
43+
"goto-bus-stop/setup-zig@*",
44+
"hadolint/hadolint-action@*",
45+
"hashicorp/setup-terraform@*",
46+
"haskell-actions/hlint-run@*",
47+
"haskell-actions/hlint-setup@*",
48+
"haskell-actions/setup@*",
49+
"hyperpolymath/*",
50+
"ibiqlik/action-yamllint@*",
51+
"ionos-deploy-now/deploy-to-ionos-action@*",
52+
"ionos-deploy-now/retrieve-project-info-action@*",
53+
"ionos-deploy-now/template-renderer-action@*",
54+
"jetli/wasm-pack-action@*",
55+
"julia-actions/cache@*",
56+
"julia-actions/julia-processcoverage@*",
57+
"julia-actions/setup-julia@*",
58+
"KSXGitHub/github-actions-deploy-aur@*",
59+
"ludeeus/action-shellcheck@*",
60+
"lycheeverse/lychee-action@*",
61+
"mlugg/setup-zig@*",
62+
"ocaml/setup-ocaml@*",
63+
"orhun/git-cliff-action@*",
64+
"ossf/scorecard-action@*",
65+
"peaceiris/actions-gh-pages@*",
66+
"peaceiris/actions-hugo@*",
67+
"peter-evans/create-pull-request@*",
68+
"peter-evans/repository-dispatch@*",
69+
"pnpm/action-setup@*",
70+
"pypa/gh-action-pypi-publish@*",
71+
"r-lib/actions@*",
72+
"reactivecircus/android-emulator-runner@*",
73+
"returntocorp/semgrep-action@*",
74+
"ruby/setup-ruby@*",
75+
"rustsec/audit-check@*",
76+
"shivammathur/setup-php@*",
77+
"slsa-framework/slsa-github-generator@*",
78+
"snyk/actions@*",
79+
"softprops/action-gh-release@*",
80+
"SonarSource/sonarcloud-github-action@*",
81+
"Swatinem/rust-cache@*",
82+
"taiki-e/install-action@*",
83+
"trufflesecurity/trufflehog@*",
84+
"webfactory/ssh-agent@*"
85+
]
86+
}

scripts/check-allowed-actions.sh

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
#!/usr/bin/env bash
2+
# SPDX-License-Identifier: MPL-2.0
3+
# check-allowed-actions.sh — make the un-squabble-able startup_failure legible.
4+
#
5+
# When a repo sets `allowed_actions: selected` (RSR default) and a workflow
6+
# `uses:` an action or reusable-workflow the allowlist does not permit, the run
7+
# dies at STARTUP with no job and no log — an opaque red you cannot "squabble"
8+
# with (RSR-PHILOSOPHY: reach green by satisfying the gate, never admin-override).
9+
#
10+
# This check turns that into a concrete, satisfiable gate: it lists exactly which
11+
# `uses:` are not covered by the canonical allowlist, so the fix is obvious
12+
# (add the pattern, or run set-allowed-actions.sh). Needs NO API and NO admin —
13+
# safe to run in CI, in rhodibot, or locally.
14+
#
15+
# Usage: check-allowed-actions.sh [ALLOWED_ACTIONS_JSON] [WORKFLOWS_DIR]
16+
# Exit: 0 = every `uses:` is covered; 1 = one or more gaps (printed).
17+
set -euo pipefail
18+
CANON="${1:-rhodium-standard-repositories/actions-allowlist/allowed-actions.json}"
19+
WF_DIR="${2:-.github/workflows}"
20+
21+
if [ ! -f "$CANON" ]; then
22+
# fall back to the published canonical list in standards
23+
CANON_URL="https://raw.githubusercontent.com/hyperpolymath/standards/main/rhodium-standard-repositories/actions-allowlist/allowed-actions.json"
24+
TMP="$(mktemp)"; trap 'rm -f "$TMP"' EXIT
25+
curl -fsSL "$CANON_URL" -o "$TMP" && CANON="$TMP" || { echo "!! no allowlist ($CANON)"; exit 2; }
26+
fi
27+
28+
python3 - "$CANON" "$WF_DIR" <<'PY'
29+
import json, sys, os, re, glob, fnmatch
30+
canon = json.load(open(sys.argv[1]))
31+
patterns = canon.get("patterns_allowed", [])
32+
gh_owned = {"actions", "github"} # github_owned_allowed
33+
34+
def owner_repo(u):
35+
base = u.split('@', 1)[0]
36+
parts = base.split('/')
37+
return '/'.join(parts[:2]), parts[0]
38+
39+
def covered(u):
40+
if u.startswith('./') or u.startswith('docker://'):
41+
return True
42+
orp, owner = owner_repo(u)
43+
if owner in gh_owned: # github-owned (actions/*, github/*)
44+
return True
45+
for p in patterns:
46+
base = p.split('@', 1)[0]
47+
if base == f"{owner}/*": # owner-wide (covers reusable workflows)
48+
return True
49+
if base == orp or fnmatch.fnmatch(orp, base):
50+
return True
51+
return False
52+
53+
uses = set()
54+
for f in sorted(glob.glob(os.path.join(sys.argv[2], '*.yml')) +
55+
glob.glob(os.path.join(sys.argv[2], '*.yaml'))):
56+
for line in open(f, encoding='utf-8'):
57+
m = re.search(r'^\s*(?:-\s*)?uses:\s*([^\s#]+)', line)
58+
if m:
59+
uses.add(m.group(1).strip().strip('"\''))
60+
61+
gaps = sorted(u for u in uses if not covered(u))
62+
for u in gaps:
63+
print(f"GAP {u} (add its owner/* or owner/repo@* pattern, or run set-allowed-actions.sh)")
64+
print(f"checked {len(uses)} `uses:` refs across {sys.argv[2]} — {len(gaps)} not covered by the allowlist")
65+
sys.exit(1 if gaps else 0)
66+
PY

scripts/set-allowed-actions.sh

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
#!/usr/bin/env bash
2+
# SPDX-License-Identifier: MPL-2.0
3+
# set-allowed-actions.sh — apply the canonical Actions allowlist to a repo.
4+
#
5+
# This is the ROOT prevention for the "empty-allowlist startup_failure" class:
6+
# when a repo is CREATED or MIGRATED to a new org, its `allowed_actions` allowlist
7+
# is reset (often to empty patterns), so every estate action/reusable is blocked
8+
# and the whole CI dies at startup. Run this on onboard/migrate so the allowlist
9+
# is never empty. This is what the repo-automaton (farm sweep) must call with an
10+
# admin token — the default GITHUB_TOKEN CANNOT change Actions policy.
11+
#
12+
# Requires: a token with repo Administration:write (fine-grained) or classic
13+
# `repo` + org owner — i.e. the farm ADMIN PAT, not the workflow GITHUB_TOKEN.
14+
#
15+
# Usage: set-allowed-actions.sh <owner/repo> [ALLOWED_ACTIONS_JSON]
16+
set -euo pipefail
17+
REPO="${1:?usage: set-allowed-actions.sh <owner/repo> [allowed-actions.json]}"
18+
CANON="${2:-rhodium-standard-repositories/actions-allowlist/allowed-actions.json}"
19+
[ -f "$CANON" ] || { echo "!! canonical allowlist not found: $CANON"; exit 2; }
20+
21+
echo "==> ensuring allowed_actions=selected + sha_pinning_required on $REPO"
22+
gh api -X PUT "repos/$REPO/actions/permissions" \
23+
-F enabled=true -f allowed_actions=selected >/dev/null
24+
25+
echo "==> applying $(python3 -c "import json,sys;print(len(json.load(open('$CANON'))['patterns_allowed']))") patterns to $REPO"
26+
gh api -X PUT "repos/$REPO/actions/permissions/selected-actions" --input "$CANON"
27+
28+
echo "==> verify"
29+
gh api "repos/$REPO/actions/permissions/selected-actions" \
30+
--jq '{patterns:(.patterns_allowed|length), github_owned:.github_owned_allowed, verified:.verified_allowed}'
31+
echo "✔ canonical allowlist applied to $REPO"

0 commit comments

Comments
 (0)