Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .agents/skills/github-actions-security/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@ For `pull_request_target`:

GitHub’s 2026 checkout hardening blocks common fork-head patterns, but it does not make arbitrary `git`, `gh`, curl, artifact, or script ingestion safe.

For a reusable workflow that accepts a caller-chosen ref (`checkout_ref` or equivalent), the safety rule belongs **inside the reusable**, not in its docs. A reusable inherits the caller's `github` context, so `github.event_name` is the event that triggered the calling run — the reusable can therefore detect a privileged caller itself:

- make the refusal the **first step**, before any checkout or setup;
- fail closed when a privileged event supplies a non-empty ref, and leave the empty-ref case alone (checking out the base repository stays legitimate);
- give it no opt-out input — a boolean that disables a security control is the control failing open;
- read the event and the ref through `env:`, never `${{ }}` inside `run:`;
- enforce it with a validator that *executes* the guard across the event matrix, so a new ref-accepting workflow cannot ship without it.

A rule that lives only in a risk note or a dismissed scanner alert is not a control. Treat "consumers must not call this from `pull_request_target`" as unenforced until a job step fails on it.

### 2. Token and permission audit

- Require top-level deny-all and minimum job scopes.
Expand Down
7 changes: 4 additions & 3 deletions .agents/skills/nddev-change-flow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ New to the repo? Read `nddev-repo-orientation` first.
5. **If you touched a skill** under `.agents/skills/`, run
`python3 scripts/sync_skills.py` to regenerate the `.claude/skills` mirror.
6. **Add a `CHANGELOG.md` entry** under `[Unreleased]`.
7. **Validate** (below), then open a PR. `main` is squash-merge-only behind the
required `ci-gate` check.
7. **Validate** (below), then open a PR. `main` takes merge commits only
(squash and rebase are disabled) behind the required `ci-gate` check.

## Editing a workflow — the security invariants

Expand Down Expand Up @@ -150,4 +150,5 @@ git diff --check
(`-S`, SSH is configured in this checkout); no `Co-Authored-By`.
- Fill `.github/PULL_REQUEST_TEMPLATE.md` — the permissions diff and
threat-model note are required for any workflow or permission change.
- Merge is squash-only after `ci-gate` is green.
- Merge is a merge commit after `ci-gate` is green; squash and rebase are
disabled on this repository.
2 changes: 1 addition & 1 deletion .agents/skills/nddev-release-flow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ A mistake ships in the next version, never as an edit to a published one.
version, not the date, but keep it honest.
3. Choose the number: this is `0.x`, so a **breaking or fail-closed contract
change lands as a minor bump**; pure fixes/docs are a patch.
4. Confirm consistency and gate, then PR + squash-merge:
4. Confirm consistency and gate, then PR + merge commit:

```bash
cat VERSION
Expand Down
8 changes: 4 additions & 4 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ prose docs as the source of truth.
5. If you touched a skill, `python3 scripts/sync_skills.py` to regenerate the
`.claude/skills` mirror from `.agents/skills`.
6. Add a `CHANGELOG.md` entry under `[Unreleased]`.
7. Validate (below), then PR — `main` is squash-merge-only behind the
`ci-gate` check.
7. Validate (below), then PR — `main` takes merge commits only (squash and
rebase are disabled) behind the `ci-gate` check.

## CI skills

Expand Down Expand Up @@ -95,8 +95,8 @@ programs), so a failure message usually names the exact broken contract.
- Conventional Commits (<100-char subject), `git commit -s` (DCO) and `-S`
(SSH signing is configured in this checkout), no `Co-Authored-By`.
- PRs fill `.github/PULL_REQUEST_TEMPLATE.md` (permissions diff +
threat-model note for workflow changes); merge is squash-only after
`ci-gate` is green.
threat-model note for workflow changes); merge is a merge commit after
`ci-gate` is green — squash and rebase are disabled.
- Release = maintainer pushes SemVer tag; `release.yml` validates
byte-exact `VERSION`, a single matching `CHANGELOG.md` heading, then
publishes five immutable checksummed assets in one create call. Never
Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
source=.agents/skills/github-actions-security/SKILL.md
sha256=4122a6426cc2f9a930c3f8ec3787e5d0889b8067c8f1094d3e99ea682203cc20
sha256=1a21a985c577e9fb7ad32bc44eddad1833e4ba03af020ce2a846293f06df77d3
10 changes: 10 additions & 0 deletions .claude/skills/github-actions-security/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@ For `pull_request_target`:

GitHub’s 2026 checkout hardening blocks common fork-head patterns, but it does not make arbitrary `git`, `gh`, curl, artifact, or script ingestion safe.

For a reusable workflow that accepts a caller-chosen ref (`checkout_ref` or equivalent), the safety rule belongs **inside the reusable**, not in its docs. A reusable inherits the caller's `github` context, so `github.event_name` is the event that triggered the calling run — the reusable can therefore detect a privileged caller itself:

- make the refusal the **first step**, before any checkout or setup;
- fail closed when a privileged event supplies a non-empty ref, and leave the empty-ref case alone (checking out the base repository stays legitimate);
- give it no opt-out input — a boolean that disables a security control is the control failing open;
- read the event and the ref through `env:`, never `${{ }}` inside `run:`;
- enforce it with a validator that *executes* the guard across the event matrix, so a new ref-accepting workflow cannot ship without it.

A rule that lives only in a risk note or a dismissed scanner alert is not a control. Treat "consumers must not call this from `pull_request_target`" as unenforced until a job step fails on it.

### 2. Token and permission audit

- Require top-level deny-all and minimum job scopes.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
source=.agents/skills/nddev-change-flow/SKILL.md
sha256=aef91f2d801e3501335c1b53f681f73b930619db6e390b70ad3a4ce1f05a24f4
sha256=2fce2bc710e42a96cc2c311487b907d177f16215c80be8152ca0fc569fe4fd72
7 changes: 4 additions & 3 deletions .claude/skills/nddev-change-flow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ New to the repo? Read `nddev-repo-orientation` first.
5. **If you touched a skill** under `.agents/skills/`, run
`python3 scripts/sync_skills.py` to regenerate the `.claude/skills` mirror.
6. **Add a `CHANGELOG.md` entry** under `[Unreleased]`.
7. **Validate** (below), then open a PR. `main` is squash-merge-only behind the
required `ci-gate` check.
7. **Validate** (below), then open a PR. `main` takes merge commits only
(squash and rebase are disabled) behind the required `ci-gate` check.

## Editing a workflow — the security invariants

Expand Down Expand Up @@ -150,4 +150,5 @@ git diff --check
(`-S`, SSH is configured in this checkout); no `Co-Authored-By`.
- Fill `.github/PULL_REQUEST_TEMPLATE.md` — the permissions diff and
threat-model note are required for any workflow or permission change.
- Merge is squash-only after `ci-gate` is green.
- Merge is a merge commit after `ci-gate` is green; squash and rebase are
disabled on this repository.
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
source=.agents/skills/nddev-release-flow/SKILL.md
sha256=23d3186d24f779571c0098da05a9d9233eb9190ac5c292e5b030510cfef4be16
sha256=7930735d57821b5b4e2939e681381c8d6cbc4030c227c6da16120c60925f3380
2 changes: 1 addition & 1 deletion .claude/skills/nddev-release-flow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ A mistake ships in the next version, never as an edit to a published one.
version, not the date, but keep it honest.
3. Choose the number: this is `0.x`, so a **breaking or fail-closed contract
change lands as a minor bump**; pure fixes/docs are a patch.
4. Confirm consistency and gate, then PR + squash-merge:
4. Confirm consistency and gate, then PR + merge commit:

```bash
cat VERSION
Expand Down
22 changes: 22 additions & 0 deletions .github/workflows/cross-platform-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,28 @@ jobs:
matrix:
os: ${{ fromJSON(inputs.os_list) }}
steps:
- name: Reject caller-supplied ref on a privileged event
shell: bash
env:
CALLER_EVENT: ${{ github.event_name }}
CHECKOUT_REF: ${{ inputs.checkout_ref }}
run: |
set -euo pipefail
# A reusable workflow inherits the caller's github context, so
# CALLER_EVENT is the event that triggered the calling run. On a
# privileged event the job holds the caller's write token and
# secrets, and checking out a caller-supplied ref would execute
# untrusted code with them. Fail closed instead.
case "$CALLER_EVENT" in
pull_request_target|workflow_run|issue_comment|issues|discussion|discussion_comment)
if [ -n "$CHECKOUT_REF" ]; then
echo "::error::cross-platform-smoke refuses checkout_ref='${CHECKOUT_REF}' on privileged event '${CALLER_EVENT}'; call it from pull_request instead" >&2
exit 1
fi
;;
esac
echo "caller event '${CALLER_EVENT}' accepted for checkout_ref='${CHECKOUT_REF:-<default>}'"

- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand Down
22 changes: 22 additions & 0 deletions .github/workflows/private-static.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,28 @@ jobs:
permissions:
contents: read
steps:
- name: Reject caller-supplied ref on a privileged event
shell: bash
env:
CALLER_EVENT: ${{ github.event_name }}
CHECKOUT_REF: ${{ inputs.checkout_ref }}
run: |
set -euo pipefail
# A reusable workflow inherits the caller's github context, so
# CALLER_EVENT is the event that triggered the calling run. On a
# privileged event the job holds the caller's write token and
# secrets, and checking out a caller-supplied ref would execute
# untrusted code with them. Fail closed instead.
case "$CALLER_EVENT" in
pull_request_target|workflow_run|issue_comment|issues|discussion|discussion_comment)
if [ -n "$CHECKOUT_REF" ]; then
echo "::error::private-static refuses checkout_ref='${CHECKOUT_REF}' on privileged event '${CALLER_EVENT}'; call it from pull_request instead" >&2
exit 1
fi
;;
esac
echo "caller event '${CALLER_EVENT}' accepted for checkout_ref='${CHECKOUT_REF:-<default>}'"

- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ variants, and runner-guard programs run against OS/architecture matrices.
- Conventional Commits, subject under 100 chars; DCO sign-off (`-s`);
cryptographic signature (`-S` — this checkout is configured for SSH
signing); no `Co-Authored-By` trailers.
- `main` is PR-only: squash merges, required `ci-gate` status check, signed
- `main` is PR-only: merge commits only (squash and rebase are disabled),
required `ci-gate` status check, signed
commits, linear history. Fill `.github/PULL_REQUEST_TEMPLATE.md`
completely (threat-model note and permissions diff for workflow changes).
- Releases are tag-driven and immutable: `VERSION` must equal the tag as one
Expand Down
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,49 @@

## [Unreleased]

### Security

- **`checkout_ref` delegated its own safety rule to prose.**
`private-static.yml` and `cross-platform-smoke.yml` are the only two
reusables that let the caller choose the checked-out commit. That is correct
on `pull_request`, where the job runs with a read-only token and no secrets,
and unsafe on a privileged event such as `pull_request_target` or
`workflow_run`, where the job holds the caller's write token and secrets and
the checkout would execute untrusted code with them. The rule existed only as
a sentence in the risk notes and in three dismissed code-scanning alerts —
nothing enforced it, and nothing stopped a consumer from getting it wrong.

Both workflows now open with a fail-closed guard step. A reusable workflow
inherits the caller's `github` context, so `github.event_name` is the event
that triggered the *calling* run; the guard refuses to proceed when a
privileged event (`pull_request_target`, `workflow_run`, `issue_comment`,
`issues`, `discussion`, `discussion_comment`) supplies a non-empty
`checkout_ref`. It is the first step in the job, has no opt-out input, and
reads both values through the environment, so no step runs before the
refusal. A privileged caller that supplies no `checkout_ref` is untouched —
the default checkout of the base repository stays legitimate.

New `scripts/check_privileged_ref_guard.py` (wired into `validate_all` as
`privileged-ref-guard`) discovers every workflow exposing `checkout_ref`,
asserts the guard is the first step of every checking-out job, and then
**executes** the extracted guard body against the full privileged/safe event
matrix — so the contract is proven by behaviour, not by pattern matching. A
future reusable that adds `checkout_ref` without the guard fails the gate.

### Documentation

- **Eight files told agents `main` was squash-merge-only; it has not been.**
The declared ruleset `.github/rulesets/branch-main.json` sets
`"allowed_merge_methods": ["merge"]`, and the live repository settings agree
(`allow_squash_merge: false`, `allow_rebase_merge: false`,
`allow_merge_commit: true`) — merge commits are the only permitted method,
matching the estate-wide atomic-commit policy. `README.md`, `SECURITY.md`,
`AGENTS.md`, `.claude/CLAUDE.md`, `docs/08-governance-rulesets.md`, and the
`nddev-change-flow` / `nddev-release-flow` skills all still described the
opposite, sending every agent that read them toward a merge method the
repository rejects. Corrected in the authored sources and remirrored into
`.claude/skills/`.

## [0.13.2] - 2026-08-01

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ has `pre` and `post` entry points that GitHub can execute even when a step-level
## Governance

`main` and release tags are protected by **rulesets** in
[`.github/rulesets/`](.github/rulesets/) (pull-request-only squash merges,
[`.github/rulesets/`](.github/rulesets/) (pull-request-only merge commits,
resolved review threads, signed commits, linear history, the strict `ci-gate`
status check, and tag protection). The repository's solo-maintainer rule does
not require an impossible self-approval; reusable projects with independent
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ response as soon as reasonably possible.
- `check_harden_runner_contract.py` rejects conditional Harden-Runner steps and
any paid runtime-hardening reference in cross-tier/private-free workflows.
- `main` is protected by a repository ruleset (`.github/rulesets/branch-main.json`):
pull-request-only squash merges, resolved review threads, signed commits,
pull-request-only merge commits, resolved review threads, signed commits,
linear history, no force-push or deletion, and the strict `ci-gate` status
check. The solo-maintainer repository does not require self-approval. Release
tags are
Expand Down
6 changes: 4 additions & 2 deletions catalog/capabilities.yml
Original file line number Diff line number Diff line change
Expand Up @@ -288,8 +288,9 @@ capabilities:
- "Use OS-specific command overrides when a smoke command is not portable across Linux, macOS, and Windows"
- "macOS and Windows runner minutes are billed at a multiplier on private repos"
- "Cross-tier contract intentionally contains no paid runtime-hardening action"
- "checkout_ref lets the caller pick the checked-out code; the workflow fails closed when a privileged caller event (pull_request_target, workflow_run, issue_comment, issues, discussion, discussion_comment) supplies one"
deprecations: null
last_verified: "2026-07-04"
last_verified: "2026-08-02"
sources:
- "https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners"

Expand Down Expand Up @@ -752,8 +753,9 @@ capabilities:
required_settings: []
risks:
- "Runs an arbitrary caller-supplied command; keep the command trusted and least-privilege"
- "checkout_ref lets the caller pick the checked-out code; the workflow fails closed when a privileged caller event (pull_request_target, workflow_run, issue_comment, issues, discussion, discussion_comment) supplies one"
deprecations: null
last_verified: "2026-07-04"
last_verified: "2026-08-02"
sources:
- "https://docs.github.com/en/actions/using-workflows/reusing-workflows"

Expand Down
21 changes: 21 additions & 0 deletions docs/02-private-free.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,24 @@ jobs:
timeout_minutes: 10
```

### `checkout_ref` and privileged events

`private-static.yml` and `cross-platform-smoke.yml` are the only two reusables
that let the caller choose the checked-out commit via `checkout_ref`. Both open
with a **fail-closed guard**: because a reusable workflow inherits the caller's
`github` context, the workflow can see the event that triggered the *calling*
run, and it refuses to start when a privileged event —
`pull_request_target`, `workflow_run`, `issue_comment`, `issues`, `discussion`,
`discussion_comment` — supplies a non-empty `checkout_ref`.

That combination would run caller-chosen code with the base repository's write
token and secrets. Pass `checkout_ref` from a `pull_request` wrapper instead,
where the token is read-only. A privileged caller that supplies no
`checkout_ref` is unaffected — checking out the base repository stays
legitimate. There is no opt-out input; the guard is enforced by
`scripts/check_privileged_ref_guard.py`, which executes it against the full
event matrix on every gate run.

## Release supply chain without attestations (`release-supply-chain-free.yml`)

GitHub Artifact Attestations are **not available to private or internal
Expand Down Expand Up @@ -132,6 +150,9 @@ to stay free-minimal, or accept the multiplier cost intentionally. Use
`linux_command`, `macos_command`, or `windows_command` when one platform needs a
lighter smoke than the default `command`.

It carries the same `checkout_ref` privileged-event guard as `private-static.yml`
(see [`checkout_ref` and privileged events](#checkout_ref-and-privileged-events)).

## Self-hosted caveats

Self-hosted runners avoid GitHub minute billing but move trust and cost onto
Expand Down
2 changes: 1 addition & 1 deletion docs/08-governance-rulesets.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ version-controlled and reviewable.
| Block deletion | Protect the branch |

This is the recommended multi-maintainer baseline. This repository's live
solo-maintainer variant still requires a pull request, squash-only merge,
solo-maintainer variant still requires a pull request, merge-commit-only merge,
resolved review threads, signed commits, linear history, no
force-push/deletion, and the strict `ci-gate` check, but sets approvals to zero
because GitHub does not allow an author to approve their own pull request.
Expand Down
Loading
Loading