feat(runner-policy): register v0.14.2 contracts for five ci-workflows reusables - #382
Conversation
… reusables Adds reviewed approvedReusableWorkflowContracts entries at 7107b34832a7b6db5d08d3b132621c599fbe5e50 (v0.14.2) for link-check, semantic-pr, do-not-merge-gate, pr-issue-linkage, and zizmor, so consumers pinned to older SHAs can converge. Each entry copies its terms forward from that workflow's newest previously approved SHA; no existing entry changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yvHrEronHPoznT1b3HtN5
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
|
Claude finished @kyle-sexton's task in 3m 23s —— View job Code Review
No issues found. This is a well-scoped, additive-only change and it checks out under manual/static review:
Tooling note: this review lane's sandbox declined to execute No inline comments — nothing to anchor to a line; the change is straightforwardly correct. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4f3c3d32dd
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
…res (#384) ## Summary Add `allowedCallerPermissions` (`contents: read`, `security-events: write`) to the `zizmor.yml@7107b34832a7b6db5d08d3b132621c599fbe5e50` reusable-workflow contract. ## Fix #382 registered the v0.14.2 contract for `zizmor.yml` without a caller-permission waiver. That entry is incomplete. At `7107b34` the reusable declares `security-events: write` on its inner job, and a reusable workflow cannot elevate caller permissions — so the **calling job** must grant it. The workflow says so itself, twice: - header: *"security-events: write on the calling job — reusable workflows cannot elevate caller permissions. The job-level security-events: write below…"* - `paths` input description: *"Callers must grant security-events: write…"* Without the waiver, a selector-routed caller granting that scope is rejected by the gate. Observed verbatim in `melodic-software/provisioning` while attempting to converge its pin: ``` .github/workflows/ci.yml#zizmor: hosted-exception-required: write GITHUB_TOKEN permissions (security-events) requires a hosted exception with reason privileged-control-plane .github/workflows/ci.yml#zizmor: privileged-hosted-only: write GITHUB_TOKEN permissions (security-events) cannot use selector or reusable local-runner routing ``` That leaves a consumer only two bad options: weaken the gate, or take a `privileged-control-plane` exception that forces a purely **advisory** lint lane off selector routing onto fixed hosted runners. Neither is acceptable, so `melodic-software/provisioning#284` held that one reference back while converging the other four. `link-check.yml@7107b34` already carries the equivalent waiver for its `issues: write`, so this restores parity rather than introducing a new shape. ## Verification - `node components/runner-policy/runner-policy.test.mjs` — **264 tests, 264 pass, 0 fail**. - The `security-events: write` value satisfies the validator's existing rule that `allowedCallerPermissions` contain at least one `write`. - Callee permissions read from `zizmor.yml` at `7107b34` directly: top-level `contents: read`; inner job `contents: read` + `security-events: write`. - Local lefthook gates pass: typos, editorconfig, gitleaks, biome. - Corroborating evidence that the grant is genuinely required at runtime, from `melodic-software/ci-runner`: commit `381fe535` bumped the zizmor pin without the caller grant and its `ci` run concluded `startup_failure`; `52068787` ("grant zizmor security-events write") is what fixed it. Scope is one entry — no schema, validator, or other contract changed. ## Related No linked issue. - #382 — registered the incomplete entry this corrects. - #383 — the separate, still-open gap: contracts cannot express a **minimum required** caller scope. Distinct from this, which is an exact-match waiver and expressible today because `security-events: write` is a write. - melodic-software/provisioning#284 — held the zizmor reference pending this. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_013yvHrEronHPoznT1b3HtN5 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
) ## Summary `approvedReusableWorkflowContracts` could waive an *exact* caller permission set but could not require a **minimum** one, so a consumer repinning to a reusable that newly requests a read scope passed `Runner policy` while granting less than the callee needs. This adds the missing term, `minimumCallerPermissions`, and backfills it onto the three `7107b34` gate contracts. `allowedCallerPermissions` is unchanged — still an exact-match waiver the validator refuses unless it carries at least one `write`. No existing contract entry, validator branch, or check is relaxed or removed. The floor may only require `read`. That is the resolution of the Codex P2 on this PR, and it is what makes the two fields exactly complementary rather than overlapping — reasoning below. ### The term, and why it is separate rather than a relaxation of the waiver The two fields answer different questions and neither implies the other. | | `allowedCallerPermissions` | `minimumCallerPermissions` | |---|---|---| | Direction | ceiling — the exact set a caller may present | floor — the least a caller must grant | | What it does | **waives** the ordinary read-only caller boundary so a reviewed workflow may hold a privileged grant | **grants nothing, waives nothing**; records what the callee's own `permissions:` block requests | | Values | must contain at least one `write` | `read` only — a write floor is rejected at policy load | | Match semantics | exact, per scope | ordered, per scope; more passes | The two are exactly complementary, and that is not incidental — see the Codex P2 resolution below. Every **write** obligation already has a home: the waiver, reviewed against the calling job, which is the route #384 just used for `zizmor.yml@7107b34`'s `security-events: write`. Every **read** obligation now has one too. Merging them into a single field was considered and rejected: the write requirement on the waiver is load-bearing — it is what makes the field a *privilege* waiver rather than a mirror of the callee's declared permissions — and the obligation #383 was filed about is entirely read, exactly the shape that requirement makes inexpressible. ### Codex P2, resolved by construction: no write floor can exist A floor that could require `write` would be unsound. GitHub downgrades a caller's write grants to read — and write-only scopes to none — on **forked** and **Dependabot** pull requests unless repository settings permit otherwise, so a caller's declared `write` is not the access the callee receives. A write floor compared against the YAML declaration would pass exactly the callers it exists to catch. Rather than model event-time downgrades — the policy can read neither repository settings nor fork/Dependabot context at validation time, so such a check would be a guess dressed as a check — the term is **restricted to `read` values**. That disposes of the finding by construction rather than by argument: no write floor can be declared, so none can be silently downgraded. The rule lives in the validator rather than the schema so the author of a rejected contract is told *why*. That is the split `allowedCallerPermissions` already uses in the opposite direction — the schema permits an all-read waiver, and the validator rejects it with "must include at least one write permission". Verified behavior, not intended: running `validatePolicy` over the real `policy.json` with the floor on `semantic-pr@7107b34` mutated gives ```text REJECTED {"actions":"write"} reusable workflow contract <ref>.minimumCallerPermissions must require read access only (actions); GitHub downgrades caller write grants on forked and Dependabot pull requests, so a write floor cannot be proven from the caller's declaration — use allowedCallerPermissions for a write obligation REJECTED {"id-token":"write"} … must require read access only (id-token); … REJECTED {"pull-requests":"read","contents":"write"} … must require read access only (contents); … REJECTED {"actions":"none"} <ref>.minimumCallerPermissions.actions must NOT be valid REJECTED {"id-token":"read"} <ref>.minimumCallerPermissions.id-token must be equal to one of the allowed values ACCEPTED {"actions":"read"} ``` The last two are the schema's own value domain: a floor value must name a real grant, and `id-token` is a write-only scope with no `read` level to require. ### How permission comparison is ordered The restriction is on what a contract may **require**, not on how grants compare — the ordered comparison is unchanged. GitHub access is ordered `none` < `read` < `write`, and a called workflow can only *downgrade* the caller's `GITHUB_TOKEN`, never elevate it ([reusable-workflow docs][rw]), so the check remains a floor, not a match: - a caller granting `write` where the contract requires `read` **passes**; - extra scopes the contract does not name **pass** — the floor says nothing about them; - `read-all` and `write-all` both clear a read floor — and each still clears it after an event-time downgrade lands at `read`, which is precisely why a read-only floor is sound where a write floor is not; - a scope the caller does not name is granted nothing and **fails**; - effective job permissions that are **omitted** fail closed — they resolve to repository- or organization-defined defaults this policy cannot read, so they can never *prove* the floor. Job-level permissions override workflow-level, so the comparison runs against `effectivePermissions(workflow, job)`, the same surface the existing waiver check uses. `write-all` clearing the floor is arithmetic, not absolution: the floor waives nothing, so a `write-all` caller still meets the ordinary `privileged-control-plane` rules. A regression test asserts exactly that. A contract naming **both** fields is checked for satisfiability when the policy loads: because the waiver is the only mapping such a caller may present, a waiver falling short of its own floor would admit nothing at all, so it is rejected as a configuration error rather than left to fail silently at every call site. ### Auto-approval: deliberately *not* a decline category `selectorResultInput`, `allowedCallerPermissions`, and a nonempty `allowedSecrets` each decline Dependabot auto-approval unconditionally, because each is trusted for something the surface diff never inspects — what the callee's *steps* do. `minimumCallerPermissions` is the opposite kind of term: it says nothing about steps, only what the callee's `permissions:` block requests, and that block is already part of the compared surface. A bump that changes it is declined by the diff; a bump that does not carries the same floor. So the term is **added to `reviewedContractSurface`** — two surface-matching bases holding different floors must still be caught as ambiguous, and there is a test for that — but **not** to the decline list. (Moot for the three entries here: all three carry `selectorResultInput` and are already declined unconditionally.) ### Correction to the backfill list I was given The task brief said `semantic-pr.yml` and `do-not-merge-gate.yml` declare only `actions: read`. **They do not.** Fetched from `melodic-software/ci-workflows` at `7107b34832a7b6db5d08d3b132621c599fbe5e50`, each of the three declares exactly one workflow-level `permissions:` block, with no job-level override anywhere in the file: | Reusable | `permissions:` at `7107b34` | Backfilled floor | |---|---|---| | `semantic-pr.yml` (L93) | `pull-requests: read`, `actions: read` | both | | `do-not-merge-gate.yml` (L45) | `pull-requests: read`, `actions: read` | both | | `pr-issue-linkage.yml` (L61) | `pull-requests: read`, `actions: read` | both | This matches the table already recorded in #382's own body, so the brief's list was the outlier. Each floor is the callee's whole declared set: the callee narrows to that set, so a caller granting any less starves it. ## Blast radius **No consumer breaks today. Nothing in the fleet needs a change before or after this merges.** Verified against the **live default branches** via `gh api`, not local clones. The floor is keyed to `path@SHA`, so only callers pinned at `7107b34` for these three paths are governed at all. | Repo | Runs `runner-policy`? | Pins at `7107b34` for these three? | Effect | |---|---|---|---| | `provisioning` | **yes** — `.github/runner-policy.json` and the managed materialization both present | **yes**, all three (converged by melodic-software/provisioning#284) | **governed and compliant** — each calling job already grants `pull-requests: read` **and** `actions: read` | | `ci-runner` | **no** — neither `.github/runner-policy.json` nor `.github/standards/runner-policy/` exists (HTTP 404 on both) | yes, all three | none; and its caller jobs already grant both grants too, so it would pass if it adopted the component | `provisioning` was re-verified after #284 merged mid-flight, against its **live default branch**, in two ways: `gh api` on all three caller files, confirming the `7107b34` pin and both `read` grants on each calling job; and the component from **this branch** run over a `git archive origin/main` export of that tree with real owner evidence — ```text GITHUB_REPOSITORY=melodic-software/provisioning CI_REPOSITORY_VISIBILITY=private \ node components/runner-policy/runner-policy.mjs --root <provisioning@origin/main> \ --policy <this branch>/components/runner-policy/policy.json Runner policy passed. ``` Identical result under `origin/main`'s policy, so this PR changes nothing for it. **What the gate actually buys, then:** every caller still on an older SHA — `standards` itself, `.github`, `dotfiles`, `medley`, `github-iac`, `claude-code-plugins`, `codex-plugins` — grants `permissions: {}` or `pull-requests: read` on its gate jobs. Each of those now fails **pre-merge, in the repin PR**, instead of at workflow startup or with a runtime 403 in the cancelled-prerequisite resolver. `provisioning` reaching the same end state by hand, in #284, is the case for the gate rather than against it: nothing forced that convergence to include the grants, and nothing would have caught it had it not. That is why the change lands with zero present-day breakage — it catches the *next* repin, not the current state. Delivery is gated too: `components/runner-policy/policy.json` is a `managed` component in `distribution/sync-manifest.yml`, so the tightened policy reaches each consumer through a reviewed sync PR, never at this PR's merge. ## Test plan Real results, run on this branch, rebased onto `origin/main` at `0fb6464` (post-#384). - `node --test components/runner-policy/runner-policy.test.mjs` — **272 pass, 0 fail**. `origin/main` measured the same way (`git archive origin/main` into a clean tree) is **264 pass, 0 fail**; +8 net tests, three of them table-driven case sets. - `npm run lint:runner-policy` — **Runner policy passed.** - **Schema:** `validatePolicy` compiles `policy.schema.json` with Ajv 2020 (`strict: true`) on every run above, so the lint and test runs are schema-validating runs. `githubMinimumPermissionMap` was additionally probed directly against the component's own Ajv 8.20.0 with its real options before being adopted: it compiles clean under `strict: true`, accepts `{"actions":"read"}`, and rejects `{"actions":"none"}`, `{}`, `{"id-token":"read"}`, `{"models":"write"}`, and unknown scopes — inheriting the whole 17-scope table and its per-scope constraints through `$ref` rather than duplicating them. The read-only rule sits in the validator, not here, for the message-quality reason given above. - `npm run lint:md` — 0 issues, 112 files. `lefthook run pre-commit` — typos, editorconfig, gitleaks, markdownlint, biome all pass. - Neighbouring components, to show nothing cross-broke: `test:packages` 14/14, `test:concurrency-policy` 24/24, `test:dependabot-policy` 35/35, `test:pr-convention-policy` 10/10, `test:lefthook-dotnet` 12/12, `lint:hooks` "All good", `lint:concurrency-policy` and `lint:dependabot-policy` pass. (`lint:pr-convention-policy` fails identically on `origin/main` in this environment — its npm script self-checks with a `$(cat …)` substitution Windows `cmd` does not expand. Untouched here.) ### Proof the new validation bites The unit tests cover the semantics; this is the end-to-end proof against the **real** backfilled `policy.json` and this repository's **own real callers**. `standards`' `.github/` at `origin/main` was exported to a scratch root, the three gate callers repinned to `7107b34`, only the caller job's `permissions:` varied, and `auditRepository` run with auto-approval disabled and `fetch` stubbed to throw so nothing could pass by network. Against **this branch's** `policy.json`: ```text ### today's grant, repinned to 7107b34 (permissions: pull-requests: read) .github/workflows/do-not-merge.yml [runner-target-contract] reusable workflow caller permissions.actions must grant at least "read"; the grant is "none" .github/workflows/pr-issue-linkage.yml [runner-target-contract] reusable workflow caller permissions.actions must grant at least "read"; the grant is "none" .github/workflows/pr-title.yml [runner-target-contract] reusable workflow caller permissions.actions must grant at least "read"; the grant is "none" ### exactly the reviewed minimum (pull-requests: read, actions: read) (no findings) ### more scopes than the minimum (read-all) (no findings) ### higher access than the minimum (pull-requests: write, actions: read) (no findings) ### no grant at all (permissions: {}) .github/workflows/do-not-merge.yml [runner-target-contract] reusable workflow caller permissions.actions must grant at least "read"; the grant is "none" .github/workflows/pr-issue-linkage.yml [runner-target-contract] reusable workflow caller permissions.actions must grant at least "read"; the grant is "none" .github/workflows/pr-title.yml [runner-target-contract] reusable workflow caller permissions.actions must grant at least "read"; the grant is "none" ``` Against **`origin/main`'s** `policy.json`, those same five scenarios produce `(no findings)` every time — including both under-granted ones. That is the gap this PR closes, reproduced rather than asserted. The check reports the **first** shortfall in sorted scope order, matching `exactCanonicalMap`'s existing first-failure style, which is why the `permissions: {}` scenario names `actions` and stops rather than also listing the equally-missing `pull-requests`. ### New tests - `an all-read minimum admits a caller granting exactly it` — the case that is inexpressible on `main`. - `a caller granting more than the minimum clears it` — a `write` grant against a `read` floor, on a contract carrying **both** terms. This is the load-bearing proof that the ordered comparison survived the read-only restriction: only what a contract may *require* narrowed, not how grants are compared. - `a caller granting less than the minimum is rejected` — omitted scope, explicit `{}`, and an unrelated scope granted instead. - `a minimum caller permission floor cannot require write access` — `write` on a read/write scope, `id-token: write`, and a mixed map with one write value; all rejected at config-validation time. **New for the P2.** - `read-all and write-all callers both clear a read floor`, asserting that `write-all` is nonetheless still caught by the privileged rules. - `a caller with no explicit permissions cannot prove a minimum`. - `a minimum caller permission scope must name a real read grant` — `none`, unknown scopes, `{}`, and `models: write`. - `a contract naming both caller-permission terms must be satisfiable`, re-targeted to a read floor the waiver omits entirely (the previous `write` floor is no longer a legal contract). One row was added to the existing `Dependabot SHA bump declines ambiguous surface-matching reviewed contracts` table so two bases differing **only** in `minimumCallerPermissions` are proven to be caught. ### Not done here, deliberately - **`hosted-only` contracts do not get the term.** Not an oversight and not scope-trimming: `reusableWorkflowStatus` returns approved for `hosted-only` before any permission check runs, so extending the floor there means extending that path — a behavior change to a routing mode with no consumer in this issue. It belongs in its own change with its own review. - **No consumer repins.** Nothing downstream needs one; see Blast radius. - **`ci-runner`'s callers pass no `runner` input**, so they would fail the `runner-input` contract for an unrelated, pre-existing reason if `ci-runner` ever adopts the component. Already flagged in #382; unchanged by this PR. ### Proposal, not implemented: should the term be required? Raised rather than built, per the brief. **Recommendation: yes eventually, as a registration-time check with a migration first — not now.** The honest shape of the rule, after the read-only restriction, is "a `runner-input` contract must record the **read** scopes of its callee's `permissions:` block as its floor" — the callee's write scopes stay the waiver's business, as `link-check`'s `issues: write` and `zizmor`'s `security-events: write` already are. Requiring it today would invalidate every existing entry at once, including several this PR does not touch, and the check cannot derive the callee's block itself without a network fetch at policy-load time, which the module deliberately does not do outside the auto-approval path. The workable sequence is the one #382 proposed for untagged SHAs: backfill the floor onto the remaining entries first, then add the requirement as a registration-time check on `approvedReusableWorkflowContracts`, so the failure lands on whoever adds an entry rather than on every downstream consumer simultaneously. Worth its own issue once the backfill is complete. Closes #383 ## Related - #382 — registered the `7107b34` entries and recorded this gap in its own body as a known limitation; this PR closes it - #384 — waived `zizmor.yml@7107b34`'s `security-events: write` through `allowedCallerPermissions`; this branch is rebased on it, and it is the worked example of the write half of the ceiling/floor split - #381 — the convergence effort those entries exist to unblock - melodic-software/provisioning#284 — converged provisioning's three gate callers to `7107b34` with both `read` grants while this PR was open; the blast-radius table reflects the post-merge state - melodic-software/ci-runner#250 — the adjacent, worse-behaved caller-contract failure mode: rejected at startup, with the required check emitting no context at all - ci-workflows#458 — the cancelled-vs-`timed_out` prerequisite resolver behind the `actions: read` additions [rw]: https://docs.github.com/en/actions/reference/workflows-and-actions/reusable-workflows 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_013yvHrEronHPoznT1b3HtN5 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
Registers reviewed
approvedReusableWorkflowContractsentries at7107b34832a7b6db5d08d3b132621c599fbe5e50(v0.14.2) for the fivemelodic-software/ci-workflowsreusables downstream repositories consume butstandardsdoes not, so those repositories can converge. Additive only — noexisting entry is removed or modified, no schema, validator, or check is
relaxed.
components/runner-policy/README.mdgains the per-revision reviewnarrative this registry documents every registered revision with.
All five are included. Nothing was excluded.
The derived contracts
Each was derived from the reusable's actual definition at
7107b34(fetchedfrom
melodic-software/ci-workflowsvia the contents API) and compared againstthe entry for the SHA it is pinned at today.
allowedInputslink-check.ymlrunner, args, issue-title, issue-labels, issue-type, auto-close@90f1c54, includingallowedCallerPermissions: {contents: read, issues: write}semantic-pr.ymlrunner, prerequisite-result@380612ado-not-merge-gate.ymlrunner, prerequisite-result, label@380612apr-issue-linkage.ymlrunner, prerequisite-result, exempt-authors@e944387zizmor.ymlrunner, paths, fail-on-severity@90f1c54(runner, paths) addsfail-on-severity; identical to@31a5b76, whichstandardsandclaude-code-pluginsalready pin and passAll five use
routing: runner-input/runnerInput: runnerwithallowedSecrets: {}(none of the five declaressecrets:).selectorResultInput: prerequisite-resultcarries forward onsemantic-pr,do-not-merge-gate, andpr-issue-linkage.Why the outgoing allowlists were carried forward rather than widened
allowedInputsis a caller-side allowlist, not the callee's declared inputset:
runner-policy.mjsfilters caller-suppliedwith:keys against it, andthe file's precedent is unambiguous —
semantic-pr@90f1c54declares nineinputs and allows two;
zizmor@90f1c54declares seven and allows two. Wideningeach entry to the callee's full declared set would grant reach no caller asked
for. The allowlists here are the union of what fleet callers actually pass:
zizmorgainsfail-on-severityonly becausestandards' ownci.ymlandclaude-code-pluginspass it at@31a5b76.zizmor's newupload-sarifinput is deliberately left out. UploadingSARIF requires the caller to grant
security-events: write, which is awrite-capable
allowedCallerPermissionswaiver and owes its own review.Excluding it from the allowlist makes that state unreachable rather than
merely unwise: a caller enabling
upload-sarifwithout the grant gets afailing upload step, not a no-op.
The
actions: readadditionsdo-not-merge-gate,semantic-pr, andpr-issue-linkageare the three thatadded
actions: readat v0.14.2 for the cancelled-vs-timed_outprerequisiteresolver (ci-workflows#458). Confirmed by parsing each definition at both SHAs:
permissions:at outgoing SHApermissions:at7107b34do-not-merge-gate.ymlpull-requests: readpull-requests: read,actions: readsemantic-pr.ymlpull-requests: readpull-requests: read,actions: readpr-issue-linkage.yml{}pull-requests: read,actions: readlink-check.ymlcontents: read,issues: writezizmor.ymlcontents: readcontents: read,security-events: writefor the opt-in SARIF uploadThis is correctly encoded as the absence of
allowedCallerPermissions, notits presence. Two reasons, both load-bearing:
reusable workflow contract <ref>.allowedCallerPermissions must include at least one write permission. Every scope these three request is read, so thefield is not expressible for them.
allowedCallerPermissionsis the write-capable waiver, not a mirror of thecallee's declared permissions. Omitting it keeps the ordinary read-only
caller boundary, which is exactly what these three need.
Which of the five a future bump can auto-approve, for the reviewer's planning:
only
zizmor.semantic-pr,do-not-merge-gate, andpr-issue-linkagearedeclined unconditionally because they carry
selectorResultInput, andlink-checkbecause it carries anallowedCallerPermissionsgrant — bothcategories are refused outright in
resolveAutoApprovedContracts. Adding awaiver to the three gate contracts would therefore forfeit nothing they still
have; it is simply not expressible, per the bullet above.
The caller-side obligation is real but lives in each consuming repository, not
here: a called workflow can only downgrade the caller's
GITHUB_TOKENpermissions, never elevate them
(https://docs.github.com/en/actions/reference/workflows-and-actions/reusable-workflows),
so every caller must grant, on its own job and in the same change as the pin,
the scopes the callee declares at
7107b34:do-not-merge-gate,semantic-pr— addactions: readalongside thepull-requests: readthey already grant.pr-issue-linkage— bothpull-requests: readandactions: read. Itscaller jobs declare
permissions: {}fleet-wide today, and this one affectsthe normal path, not only the cancelled-prerequisite path.
ci-runner#250 is the precedent for how badly this can present — a required
check emitting no context at all rather than a red check.
Known gap: policy cannot enforce a minimum caller scope
Raised by the Codex review lane on this PR and worth recording rather than
burying. A consumer can repin to
7107b34without granting the scopes aboveand still pass
Runner policy: no contract in this registry expresses aminimum caller permission.
allowedCallerPermissionsis an exact-matchwaiver the validator refuses unless it contains at least one
write, so for acallee whose whole requested set is read it is not expressible.
This is a pre-existing, model-wide property — every entry without that field
has it — and withholding these three approvals would enforce nothing while
keeping consumers stuck, so they are kept. The runtime consequence is also
narrower than it first looks: in all three, the API call sits behind
if: inputs.prerequisite-result == 'cancelled', and its catch block callscore.setFailednaming the missing scope, so a converged-but-under-grantedcaller gets a red, self-diagnosing check rather than a silent one.
The enforceable fix — a new optional minimum-scope contract term, or a
permissions analogue of the repo-local
requiredReusableCallInputscomplement— is a schema-plus-validator design change, deliberately out of scope for a PR
whose remit is to add reviewed entries and change no schema or validator.
Recommended as a follow-up issue against this component.
Correction to the issue's table
The issue attributes
link-check's auto-approval decline to changed inputs.It is not. Running the module's own exported comparator
(
reusableWorkflowSecuritySurfacesMatch) over the real fetched sources gives:diffFieldzizmor.yml90f1c54/31a5b76inputslink-check.yml90f1c54credentialReferencesdo-not-merge-gate.yml380612apermissionssemantic-pr.yml380612apermissionspr-issue-linkage.ymle944387permissionslink-check'son.workflow_callblock is byte-identical to@90f1c54.What changed is its credential-bearing step surface: the rolling-issue open /
update / close steps moved off the
ghCLI (GH_TOKEN: ${{ github.token }})onto
actions/github-script, plus anactions/checkoutpin bump v7.0.0 →v7.0.1. That is the whole of what a reviewer must actually review for this one,
and it is why its contract is unchanged.
Test plan
Real results, all run against the edited
policy.json.node --test components/runner-policy/runner-policy.test.mjs— 264 pass,0 fail (the component's only test file; referenced in
distribution/sync-manifest.yml).npm run lint:runner-policy— Runner policy passed.Schema:
policy.jsonvalidates againstcomponents/runner-policy/policy.schema.json. The schema is not a separatecommand —
validatePolicycompiles it with Ajv 2020 and every run above goesthrough it, so both the lint and the test run are schema-validating runs.
npm run lint:md— 0 issues (112 files).biome checkonpolicy.json—clean, no formatting fixes applied. Pre-commit hooks (typos, gitleaks,
markdownlint, editorconfig, biome) all passed.
Convergence proof. Copied
standards' own.github/to a scratch root,repinned its five callers to
7107b34, addedactions: readto the threegate callers, and ran
auditRepositorywith auto-approval disabled andfetchstubbed to throw (so nothing could pass by network):origin/main'spolicy.json: 5 findings, onerunner-target-contract"the reusable workflow path@SHA has no reviewedrunner-input contract" per caller;
policy.json: 0 findings.This also empirically confirms the read-only-boundary reasoning above: the
converged callers granting
{pull-requests: read, actions: read}areadmitted with no
allowedCallerPermissionsentry present.Not done here, deliberately
PRs to repin and to add
actions: readto the three gate callers. This PRonly removes the policy-side blocker.
ci-runneris already at7107b34for all five and itszizmorcallerpasses
pathswith norunnerinput while grantingsecurity-events: write. Under arunner-inputcontract the runner inputmust be passed explicitly, so that job will need review when
ci-runneradopts this component — flagging it here so it does not resurface later as a
mystery.
Open questions from the issue
Does
zizmorwarrant aprivileged-control-planeexception rather thanstaying advisory? Recommendation: no, not yet — defer with a trigger.
The exception category exists for jobs that must run write-capable or
credential-bearing work on proven-hosted execution.
zizmorat7107b34needsnone of that: its analysis is read-only, and the only privileged surface
v0.14.2 introduces is the opt-in SARIF upload, which no fleet caller enables.
The trigger is concrete: the first caller that wants
upload-sarif: true.That caller needs
security-events: write, which forces anallowedCallerPermissionswaiver on thezizmorcontract, and a waiver onlyholds while the call is genuinely selector-routed — a fixed hosted literal
through the same input gets no waiver and lands squarely in
privileged-control-plane. Decide it then, with a concrete caller in hand,rather than pre-granting now.
Should the registry refuse untagged SHAs outright? Recommendation:
yes in principle, but it cannot land as a bare refusal — it needs a
migration step first.
380612ais not the only untagged pin.31a5b76(2026-07-20) is also untagged, andstandards' ownci.ymlandclaude-code-pluginsboth pin it forzizmortoday. A hard refusal wouldinvalidate this repository's current pin on the day it merges. Sequencing that
works:
380612aand31a5b76onto tagged revisions — which the entries in this PR make possiblefor both.
approvedReusableWorkflowContractskeys rather than a call-site check, sothe failure lands on whoever adds the entry rather than on every downstream
consumer at once.
Worth noting the refusal buys less than it looks like it does: SHA pins are
what actually make a reference immutable, and a tag is a mutable pointer. What
it really buys is provenance legibility — a SHA with no tag names a version
identity that does not exist, which is precisely how
380612aand31a5b76went unnoticed. A cheaper first move in the same direction would be requiring
the pin comment to carry a resolvable tag, which
pin-comment-conventionisalready positioned to enforce.
Closes #381
Related
actions: readcaller-contractprecedent and the invisible-missing-check failure mode
timed_outprerequisite resolver behindthe
actions: readadditions🤖 Generated with Claude Code
https://claude.ai/code/session_013yvHrEronHPoznT1b3HtN5