From fccdd409ff7468dec165a3ab8a965390c5f20fbc Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Thu, 23 Jul 2026 11:16:20 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(guards):=20actions/runner-isolated=20?= =?UTF-8?q?=E2=80=94=20the=20scan,=20the=20two-condition=20rule,=20the=20e?= =?UTF-8?q?xit=20codes=20(#58)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- actions/runner-isolated/action.yml | 32 ++++ actions/runner-isolated/runner-isolated.sh | 185 +++++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 actions/runner-isolated/action.yml create mode 100644 actions/runner-isolated/runner-isolated.sh diff --git a/actions/runner-isolated/action.yml b/actions/runner-isolated/action.yml new file mode 100644 index 0000000..24a51f6 --- /dev/null +++ b/actions/runner-isolated/action.yml @@ -0,0 +1,32 @@ +name: Runner isolated +description: >- + Assert that no `pull_request`- (or `pull_request_target`-) triggered + workflow names a self-hosted runner (#58). A pull_request workflow runs + the PR branch's code — from a fork, unreviewed code — and a self-hosted + runner executes it on our own hardware, inside our own network; the + fork-PR write-token and secrets toggles protect credentials, not the + runner. The rule is file-level, deliberately: a file whose trigger block + names pull_request and which names self-hosted anywhere fails, even + across jobs — the fix is to split the workflow. Known gaps, so silence + is never read as coverage: workflow_call reachability is not followed + (a pull_request caller plus a self-hosted callee goes unseen), and + indirection is not resolved (runner groups, matrix or expression values + for runs-on). A missing workflows directory is a pass. The caller must + have checked out its own repository first: the guard reads the + consumer's tree at the workspace. +inputs: + workflows-dir: + description: >- + Directory scanned for `*.yml`/`*.yaml` workflow files. A missing + directory passes — a guard that fails on absence is a guard nobody + adopts. + required: false + default: .github/workflows +runs: + using: composite + steps: + - name: runner isolated + shell: bash + env: + WORKFLOWS_DIR: ${{ inputs.workflows-dir }} + run: bash "$GITHUB_ACTION_PATH/runner-isolated.sh" diff --git a/actions/runner-isolated/runner-isolated.sh b/actions/runner-isolated/runner-isolated.sh new file mode 100644 index 0000000..d6a119f --- /dev/null +++ b/actions/runner-isolated/runner-isolated.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +set -euo pipefail + +# runner-isolated.sh [] — assert that no `pull_request`- +# triggered workflow in the tree names a self-hosted runner (#58; epic #56 +# decision D5). +# +# The threat, stated once: a `pull_request` workflow runs code from the +# PR's branch. When that branch comes from a fork and the repo's fork-PR +# settings do not require approval, that code is UNREVIEWED. Point such a +# job at a self-hosted runner and unreviewed code executes on our own +# hardware, inside our own network. Nothing else in the fleet's setup +# gates that path — the write-token and secrets toggles (correctly off, +# #16's ruling) protect credentials, not the runner. +# +# Nothing was wrong the day this was written: incubator's deploy.yml is +# push-triggered and self-hosted (legal), its pr-checks.yml is +# PR-triggered and hosted, and the rule lived as a sentence in +# pr-checks.yml's header, kept true by whoever remembered it. This guard +# is that sentence moved into CI — the same move drill-recorded made +# after three releases shipped through a documented-but-unenforced gate +# (its header: "that is not a gate, it is luck with good manners"). +# +# THE RULE IS FILE-LEVEL, DELIBERATELY. The precise rule — no JOB +# reachable from a pull_request trigger runs self-hosted — needs a YAML +# parser, and a second parser in bash is a new class of guard bug bought +# in exchange for permitting a file shape we do not want. So: a file +# FAILS when its trigger block names pull_request (which also matches +# pull_request_target — intended) AND it names a self-hosted runner +# anywhere, even in a different job. The false positive has a clean, +# safer fix — SPLIT THE WORKFLOW; incubator already keeps pr-checks.yml +# apart from deploy.yml, which is the shape this guard asks for. False +# NEGATIVES are what a security guard must not have, and file-level +# granularity has none for the modelled threat: it can only be stricter +# than the precise rule, never laxer. +# +# Self-hosted detection covers two shapes, because the same-line rule +# alone ("runs-on and self-hosted on one line") would pass the +# block-sequence form — a false negative, the one defect this guard is +# not allowed to have: +# +# runs-on: [self-hosted, ci-runner] # same line: caught +# runs-on: # block sequence: the bare +# - self-hosted # key opens a window over +# - ci-runner # its `- …` list items +# +# KNOWN LIMITS, named in action.yml's description too so a consumer +# never reads silence as coverage: +# - workflow_call is not treated as PR-reachable in v1: a pull_request +# caller plus a self-hosted callee is a real path this guard does not +# see. Following `uses:` across files is the YAML-parsing problem +# again, and the family has no such caller today (ceremony's own +# release-exercise.yml is ubuntu-latest). +# - indirection is not resolved: a runner group (`runs-on: {group: …}`) +# or a matrix/expression value can reach self-hosted hardware without +# the string appearing on any line this guard reads. +# +# Comments are skipped on BOTH halves of the rule: a workflow that merely +# mentions self-hosted in prose is not the bug (incubator's pr-checks.yml +# header is exactly that prose), and a guard that cried wolf on comments +# would be turned off within a week. A missing workflows directory is a +# PASS, not an error — most repos in the family have one, but a guard +# that fails on absence is a guard nobody adopts. +# +# A file of its own (not inlined in action.yml) so +# test/runner-isolated.test.sh can drive it against constructed trees — +# the same discipline as the four guards beside it. + +workflows_dir="${1:-${WORKFLOWS_DIR:-.github/workflows}}" + +if [ ! -d "$workflows_dir" ]; then + echo "runner-isolated: no workflows directory at '$workflows_dir' — nothing to scan" + exit 0 +fi + +shopt -s nullglob +files=("$workflows_dir"/*.yml "$workflows_dir"/*.yaml) + +if [ "${#files[@]}" -eq 0 ]; then + echo "runner-isolated: 0 workflow files under '$workflows_dir' — nothing to scan" + exit 0 +fi + +offenders=0 +for file in "${files[@]}"; do + pr_triggered=0 + in_on=0 + in_runs_on=0 + hits=() + + lineno=0 + while IFS= read -r line || [ -n "$line" ]; do + lineno=$((lineno + 1)) + + # A blank line ends nothing: it is not a top-level key, and a YAML + # sequence may legally continue past one. + case "$line" in + *[![:space:]]*) ;; + *) continue ;; + esac + + stripped="${line#"${line%%[![:space:]]*}"}" + + # Comment lines are invisible to both halves of the rule. + case "$stripped" in + '#'*) continue ;; + esac + + # A line starting a top-level key opens or closes the trigger block. + # YAML 1.1 parses bare `on` as a boolean, so some repos quote the + # key — a guard that missed '"on":' would silently pass the file it + # most needs to read. + case "$line" in + [![:space:]]*) + in_runs_on=0 + case "$line" in + 'on:'*| '"on":'* | "'on':"*) in_on=1 ;; + *) in_on=0 ;; + esac + ;; + esac + + # Half one: the trigger. Checked on the `on:` line itself (scalar and + # inline-list shapes) and on every line of its block. + if [ "$in_on" -eq 1 ]; then + case "$line" in + *pull_request*) pr_triggered=1 ;; + esac + fi + + # Half two: the runner. The window a bare `runs-on:` key opened over + # its list items closes at the first line that is not a `- …` item. + if [ "$in_runs_on" -eq 1 ]; then + case "$stripped" in + '-'*) + case "$line" in + *self-hosted*) hits+=("$lineno: $line") ;; + esac + ;; + *) in_runs_on=0 ;; + esac + fi + case "$line" in + *runs-on*) + case "$line" in + *self-hosted*) hits+=("$lineno: $line") ;; + esac + case "$stripped" in + 'runs-on:' | 'runs-on:'[[:space:]]*) + rest="${stripped#runs-on:}" + rest="${rest#"${rest%%[![:space:]]*}"}" + case "$rest" in + '' | '#'*) in_runs_on=1 ;; + esac + ;; + esac + ;; + esac + done <"$file" + + if [ "$pr_triggered" -eq 1 ] && [ "${#hits[@]}" -gt 0 ]; then + offenders=$((offenders + 1)) + { + echo "runner-isolated: $file is pull_request-triggered and names a self-hosted runner:" + printf ' %s\n' "${hits[@]}" + } >&2 + fi +done + +if [ "$offenders" -gt 0 ]; then + cat >&2 < Date: Thu, 23 Jul 2026 11:17:44 +0000 Subject: [PATCH 2/3] =?UTF-8?q?test(guards):=20runner-isolated=20fixture?= =?UTF-8?q?=20matrix=20=E2=80=94=2012=20spec=20rows=20plus=20the=20block-s?= =?UTF-8?q?equence=20cases=20(#58)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/runner-isolated.test.sh | 308 +++++++++++++++++++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 test/runner-isolated.test.sh diff --git a/test/runner-isolated.test.sh b/test/runner-isolated.test.sh new file mode 100644 index 0000000..8d01c6a --- /dev/null +++ b/test/runner-isolated.test.sh @@ -0,0 +1,308 @@ +#!/usr/bin/env bash +# Contract tests for actions/runner-isolated (issue #58). Constructed +# fixture trees — a dir holding a workflows directory, not git repos — +# the same discipline as the guards beside it. set -u, not -e: failing +# commands are behavior for the harness to inspect. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=test/harness.sh +. "$ROOT/test/harness.sh" + +SCRIPT="$ROOT/actions/runner-isolated/runner-isolated.sh" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +# The guard reads the consumer's tree at its working directory, so every +# case runs from inside a constructed fixture tree. +in_tree() { + local dir="$1" + shift + (cd "$TMP/$dir" && bash "$SCRIPT" "$@") +} + +# wf — write .github/workflows/ in the tree from stdin. +wf() { + mkdir -p "$TMP/$1/.github/workflows" + cat >"$TMP/$1/.github/workflows/$2" +} + +# --- the rows that must FAIL: they are the point of this file ---------------- + +# 1: block-form trigger + inline-list runner — incubator's deploy.yml +# shape with the one edit that would make it the bug. +wf block-list a.yml <<'YAML' +name: deploy checks +on: + pull_request: + types: [opened] +jobs: + deploy: + runs-on: [self-hosted, ci-runner] + steps: + - run: echo deploy +YAML +check "on: block + runs-on inline list fails" 1 "self-hosted" \ + in_tree block-list +check "failure names the offending file" 1 "a.yml" in_tree block-list +check "failure names the offending runs-on line" 1 "runs-on: [self-hosted, ci-runner]" \ + in_tree block-list + +# 2: inline-list trigger + scalar runner. +wf inline-trigger a.yml <<'YAML' +name: ci +on: [push, pull_request] +jobs: + build: + runs-on: self-hosted + steps: + - run: echo build +YAML +check "on: inline list + runs-on scalar fails" 1 "self-hosted" \ + in_tree inline-trigger + +# 3: scalar trigger + self-hosted — and a .yaml extension, so the second +# glob leg is load-bearing in at least one case. +wf scalar-trigger a.yaml <<'YAML' +name: ci +on: pull_request +jobs: + build: + runs-on: self-hosted + steps: + - run: echo build +YAML +check "on: scalar + self-hosted fails, .yaml extension scanned" 1 "a.yaml" \ + in_tree scalar-trigger + +# 4: the quoted key — YAML 1.1 parses bare `on` as a boolean, so some +# repos quote it; a guard that missed this form would silently pass the +# file it most needs to read. +wf quoted-on a.yml <<'YAML' +name: ci +"on": + pull_request: +jobs: + build: + runs-on: self-hosted + steps: + - run: echo build +YAML +check "quoted \"on\": key + self-hosted fails" 1 "self-hosted" \ + in_tree quoted-on + +# 5: pull_request_target is the same threat with a scarier token. +wf target a.yml <<'YAML' +name: ci +on: + pull_request_target: +jobs: + build: + runs-on: self-hosted + steps: + - run: echo build +YAML +check "pull_request_target + self-hosted fails" 1 "self-hosted" \ + in_tree target + +# 6: two jobs in one file — a PR-triggered hosted check AND a self-hosted +# job. Pins the file-level decision (#58 §3): a later "fix" to job +# granularity must be a deliberate change, not a silent one. +wf mixed-jobs a.yml <<'YAML' +name: ci +on: + pull_request: +jobs: + check: + runs-on: ubuntu-latest + steps: + - run: echo check + deploy: + runs-on: [self-hosted, ci-runner] + steps: + - run: echo deploy +YAML +check "file-level rule: hosted PR check + self-hosted job in one file fails" 1 \ + "self-hosted" in_tree mixed-jobs + +# The block-sequence runner — the same-line rule alone would pass this +# file, and a false negative is the one defect this guard is not allowed +# to have (the script header's why). +wf block-seq a.yml <<'YAML' +name: ci +on: + pull_request: +jobs: + deploy: + runs-on: + - self-hosted + - ci-runner + steps: + - run: echo deploy +YAML +check "block-sequence runs-on: - self-hosted fails" 1 "self-hosted" \ + in_tree block-seq + +# 11: multiple offenders across two files — BOTH named, not just the first. +wf two-files a.yml <<'YAML' +name: one +on: pull_request +jobs: + a: + runs-on: self-hosted + steps: + - run: echo a +YAML +wf two-files b.yml <<'YAML' +name: two +on: pull_request +jobs: + b: + runs-on: [self-hosted, other] + steps: + - run: echo b +YAML +check "two offending files: the first is named" 1 "a.yml" in_tree two-files +check "two offending files: the second is named too" 1 "b.yml" in_tree two-files + +# --- the rows that must PASS: the legal shapes stay legal -------------------- + +# 7: push-only + self-hosted — incubator's deploy.yml, which must stay +# legal. +wf push-deploy deploy.yml <<'YAML' +name: deploy +on: + push: + branches: [main] +jobs: + deploy: + runs-on: [self-hosted, ci-runner] + steps: + - run: echo deploy +YAML +check "push-only + self-hosted passes (deploy.yml's shape)" 0 "1 workflow file" \ + in_tree push-deploy + +# The block-sequence window with no PR trigger: the window logic must not +# widen the rule past its two conditions. +wf push-block-seq deploy.yml <<'YAML' +name: deploy +on: + push: + branches: [main] +jobs: + deploy: + runs-on: + - self-hosted + steps: + - run: echo deploy +YAML +check "push-only + block-sequence self-hosted passes" 0 "1 workflow file" \ + in_tree push-block-seq + +# 8: PR trigger on a hosted runner — incubator's pr-checks.yml. +wf pr-hosted checks.yml <<'YAML' +name: checks +on: + pull_request: +jobs: + check: + runs-on: ubuntu-latest + steps: + - run: echo check +YAML +check "pull_request + ubuntu-latest passes (pr-checks.yml's shape)" 0 \ + "1 workflow file" in_tree pr-hosted + +# 9: self-hosted in a comment only — prose is not the bug; incubator's +# pr-checks.yml header is exactly this prose. +wf comment-only checks.yml <<'YAML' +name: checks +# Unreviewed branch code must never reach the self-hosted deploy runner. +on: + pull_request: +jobs: + check: + # not: runs-on: self-hosted + runs-on: ubuntu-latest + steps: + - run: echo check +YAML +check "self-hosted in comments only passes" 0 "1 workflow file" \ + in_tree comment-only + +# 10: an empty workflows dir, and no workflows dir at all — both pass; a +# guard that fails on absence is a guard nobody adopts. +mkdir -p "$TMP/empty-dir/.github/workflows" +check "empty workflows dir passes" 0 "0 workflow files" in_tree empty-dir +mkdir -p "$TMP/no-dir" +check "missing workflows dir passes" 0 "no workflows directory" in_tree no-dir + +# 12: a schedule trigger with self-hosted and no PR trigger — the runner +# half alone is not the offence. +wf scheduled nightly.yml <<'YAML' +name: nightly +on: + schedule: + - cron: '0 3 * * *' +jobs: + sweep: + runs-on: [self-hosted, ci-runner] + steps: + - run: echo sweep +YAML +check "schedule + self-hosted, no PR trigger, passes" 0 "1 workflow file" \ + in_tree scheduled + +# The trigger-block span: pull_request below the on: block (here, a step +# name in jobs:) is not a trigger. Guards the block-end detection. +wf pr-elsewhere build.yml <<'YAML' +name: build +on: + push: + branches: [main] +jobs: + build: + runs-on: [self-hosted, ci-runner] + steps: + - name: mention pull_request in a step name + run: echo build +YAML +check "pull_request outside the on: block is not a trigger" 0 \ + "1 workflow file" in_tree pr-elsewhere + +# --- a non-default workflows dir, as arg and as the action's env var --------- + +mkdir -p "$TMP/alt-dir/ci-flows" +cat >"$TMP/alt-dir/ci-flows/a.yml" <<'YAML' +name: ci +on: pull_request +jobs: + a: + runs-on: self-hosted + steps: + - run: echo a +YAML +check "a non-default workflows dir is honored as an argument" 1 "ci-flows/a.yml" \ + in_tree alt-dir ci-flows + +# A non-default dir proves the env var is honored, not the default — +# the same wiring proof drill-recorded's suite carries. +env_tree() { + (cd "$TMP/alt-dir" && WORKFLOWS_DIR=ci-flows bash "$SCRIPT") +} +check "the env var drives the script the way action.yml does" 1 \ + "ci-flows/a.yml" env_tree + +# --- ceremony's own tree — the same tree self-guards runs against ------------ + +# A future workflow change that breaks this guard's parsing should show +# up as a unit-test failure, not only as a red CI job. +own_tree() { + (cd "$ROOT" && bash "$SCRIPT") +} +check "ceremony's own .github/workflows passes" 0 "no pull_request-triggered work" \ + own_tree + +summary From 44d0a79547f8a1307ef06a4227ec6196dc4c797d Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Thu, 23 Jul 2026 11:18:56 +0000 Subject: [PATCH 3/3] =?UTF-8?q?feat(guards):=20wire=20runner-isolated=20?= =?UTF-8?q?=E2=80=94=20self-guards=20step,=20consumer=20entry,=20changelog?= =?UTF-8?q?=20(#58)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 6 ++++-- CHANGELOG.md | 1 + docs/CONSUMERS.md | 14 ++++++++++++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b4617d..a9c2a5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,8 +52,9 @@ jobs: uses: ./.github/workflows/release-exercise.yml # The self-guards (issue #11): this repo eats exactly what it serves. The - # three guard actions run against the REAL tree — VERSION, CHANGELOG.md, - # drills/ — through the same `uses:` steps every consumer's CI carries. + # guard actions run against the REAL tree — VERSION, CHANGELOG.md, + # drills/, .github/workflows/ — through the same `uses:` steps every + # consumer's CI carries. # These steps are also the composite-action wiring proof (issue #5's # acceptance criterion: action.yml resolving, $GITHUB_ACTION_PATH, the # relative lib sourcing) that action-exercise carried with scratch files @@ -73,6 +74,7 @@ jobs: - uses: ./actions/changelog-armed - uses: ./actions/changelog-monotonic - uses: ./actions/drill-recorded + - uses: ./actions/runner-isolated # Exercises changelog-monotonic the way a consumer does, against a # CONSTRUCTED history. The self-guards job above runs the same action on diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cf773f..570debb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ so entries say what changed, cite the issue, and stop. - `needs-ruling` — the cross-cutting flag for a pending human decision, excluded from `state:needs-human` and from the staleness sweep (#51). - Cross-repo doctrine: the panel is the PR's repo's roster, a review request is authorization but not panel membership, and `Part of #N` replaces the `Closes #N` that cannot cross repos (#57). +- `actions/runner-isolated` — a `pull_request`-triggered job may never run on a self-hosted runner (#58). ## 0.1.0 — 2026-07-22 diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index 85de0c8..1f38c91 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -70,6 +70,7 @@ the machinery at all: - uses: heavy-duty/ceremony/actions/changelog-armed@ - uses: heavy-duty/ceremony/actions/changelog-monotonic@ - uses: heavy-duty/ceremony/actions/drill-recorded@ + - uses: heavy-duty/ceremony/actions/runner-isolated@ ``` `changelog-armed` and `drill-recorded` take @@ -77,6 +78,15 @@ the machinery at all: inputs and defaults are in its `action.yml` ([actions/](../actions/)). Adopting the agent team flow adds the `docs-sync` step ([below](#adopting-the-agent-team-flow)). + + `runner-isolated` asserts that no `pull_request`-triggered workflow + names a self-hosted runner — a PR workflow runs the branch's code, and + unreviewed fork code must never execute on your own hardware + ([#58](https://github.com/heavy-duty/ceremony/issues/58)). It fires on + the PR that first mixes a PR trigger and a self-hosted `runs-on` in + one file; the unblock is splitting the workflow. A repo with **no** + self-hosted runner still wants it: the guard's value is the day + somebody adds one. 6. **Labels automation** (optional but recommended): the caller from [Labels automation](#labels-automation), plus `.github/labels.conf` (panel + the repo's `scope:*` rows) and `.github/labeler.yml` (the @@ -323,9 +333,9 @@ Bumping the pin re-syncs the mirror in the same PR — [releases page](https://github.com/heavy-duty/ceremony/releases) is that section, verbatim). One bump PR updates **every** ceremony `uses:` reference in the repo to the new tag — the workflow callers *and* each - guard step; a release-only setup already has four (the + guard step; a release-only setup already has five (the [release caller](#release-workflow) plus the - [three CI guards](#bootstrap-a-new-repo)), and changing only one line + [four CI guards](#bootstrap-a-new-repo)), and changing only one line leaves the consumer split across ceremony versions, which the same-tag rule above forbids. A repo that has adopted the agent team flow additionally bumps the mirror in the same PR —