Skip to content

feat(runner-policy): add a minimum caller-permission contract term - #385

Merged
kyle-sexton merged 2 commits into
mainfrom
feat/runner-policy-minimum-caller-permissions
Aug 14, 2026
Merged

feat(runner-policy): add a minimum caller-permission contract term#385
kyle-sexton merged 2 commits into
mainfrom
feat/runner-policy-minimum-caller-permissions

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

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

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), 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 —

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.mjs272 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-policyRunner 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:

### 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 accesswrite 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 grantnone,
    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 feat(runner-policy): register v0.14.2 contracts for five ci-workflows reusables #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

🤖 Generated with Claude Code

https://claude.ai/code/session_013yvHrEronHPoznT1b3HtN5

@cursor

cursor Bot commented Aug 14, 2026

Copy link
Copy Markdown

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

claude Bot commented Aug 14, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 27s —— View job


Review complete

  • Gather context (PR body, diff, repo instructions)
  • Read changed files (runner-policy.mjs, policy.schema.json, policy.json, README.md, runner-policy.test.mjs)
  • Run tests / lint — could not run (see note below)
  • Review for correctness, security, maintainability
  • Post findings

I traced the new minimumCallerPermissions term end to end: schema (githubMinimumPermissionMap's minProperties: 1 + additionalProperties: false + the not: {const: "none"} overlay), the satisfiability check in validatePolicy (runner-policy.mjs#L273-L291), grantedPermissionAccess/minimumPermissionShortfall (runner-policy.mjs#L952-L985), the call-site check in reusableWorkflowStatus (runner-policy.mjs#L1159-L1166), and the auto-approval exclusion (runner-policy.mjs#L2019-L2025).

Everything checks out:

  • Fail-closed on missing/unprovable permissions is correct: effectivePermissions returns undefined when neither the job nor workflow declares permissions:, and isMapping(undefined) is false, so minimumPermissionShortfall reports the "must be an explicit mapping…" error rather than silently passing — verified against effectivePermissions at runner-policy.mjs#L2561-L2563.
  • write-all clears the floor but not privileged-control-plane: the carve-out in permissionHostedRequirement/localCredentialRequirement keys off allowedCallerPermissions (reviewedCallerPermissions, line ~2958), never minimumCallerPermissions, so a write-all caller against a minimum-only contract is still caught — matches the new "write-all clears any minimum" test and the PR's claim.
  • hosted-only correctly excluded: schema forbids the field there (hostedOnlyContract has no minimumCallerPermissions property, additionalProperties: false), and reusableWorkflowStatus returns approved for hosted-only routing at runner-policy.mjs#L1137-L1139, before the minimum check at L1159.
  • Auto-approval placement is correct: minimumCallerPermissions is in reviewedContractSurface (runner-policy.mjs#L1759-L1761) but not in the decline list (runner-policy.mjs#L2019-L2025, which only checks selectorResultInput and allowedCallerPermissions) — consistent with the stated rationale that the field only describes the callee's permissions: block, which the surface diff already inspects.
  • Satisfiability check for contracts naming both terms runs after ajv schema validation, so contract.allowedCallerPermissions/contract.minimumCallerPermissions are already well-formed mappings when minimumPermissionShortfall inspects them.
  • policy.json backfill matches the PR's own table: semantic-pr.yml, do-not-merge-gate.yml, and pr-issue-linkage.yml at 7107b34 all get {"pull-requests":"read","actions":"read"}; zizmor.yml is untouched (needs security-events: write, a separate concern) — verified directly in policy.json L809-L841.
  • New tests are well-targeted: exact-minimum pass, over-grant pass, three under-grant shapes (omitted/{}/wrong-level), read-all/write-all boundary, no-explicit-permissions fail-closed, schema rejection of none/unknown-scope/{}, cross-term satisfiability, and a new row in the ambiguous-surface Dependabot table. No gaps I could identify — the floor/ceiling interaction, ordering semantics, and auto-approval boundary are each independently exercised.

No correctness, security, or maintainability issues found. The separation of allowedCallerPermissions (ceiling, waives the write-boundary) from minimumCallerPermissions (floor, waives nothing) is implemented exactly as documented, and the write requirement / none-rejection asymmetry between the two schema branches is what makes that separation load-bearing rather than cosmetic.

Note: I was unable to execute node --test / npm run lint:runner-policy in this environment — Bash commands invoking node directly were blocked pending approval that wasn't available in this non-interactive lane. The review above is based on full static reading of the diff and the surrounding (unchanged) logic it composes with, not on re-running the test suite. The PR's own test-plan output (271/271 passing, schema-validating via Ajv strict: true) is consistent with what the code implements.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8a1f4a16f9

ℹ️ 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".

Comment thread components/runner-policy/runner-policy.mjs
@github-actions

Copy link
Copy Markdown

Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count.

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Verified and merging — with one correction to the blast-radius table, which went stale mid-flight.

The table says provisioning does not pin these three at 7107b34. That was true when it was written, and is no longer: melodic-software/provisioning#284 merged shortly afterwards and converged exactly semantic-pr, do-not-merge-gate, and pr-issue-linkage to 7107b34, adding the required caller grants at the same time.

So provisioning is governed by the new floor. The conclusion still holds, for a stronger reason than stated — not "out of scope", but "in scope and compliant":

Caller Grants on the calling job Floor
pr-title.ymlsemantic-pr pull-requests: read, actions: read met
do-not-merge.ymldo-not-merge-gate pull-requests: read, actions: read met
pr-issue-linkage.ymlpr-issue-linkage pull-requests: read, actions: read met

Proved rather than reasoned — this branch's checker run against the live provisioning working tree:

$ GITHUB_REPOSITORY=melodic-software/provisioning node components/runner-policy/runner-policy.mjs \
    --root <provisioning> --policy <this branch>/components/runner-policy/policy.json \
    --config <provisioning>/.github/runner-policy.json
Runner policy passed.

ci-runner confirmed unaffected: .github/runner-policy.json is absent, so it does not run this gate at all.

Tests on this branch: 271 pass, 0 fail (up from 264 on main).

Worth noting for the record: had #284 not already added those grants, this floor would have failed provisioning's gate on merge. The ordering was lucky rather than planned — a reason to keep backfilling a floor and granting the scope in the same change where possible.

kyle-sexton and others added 2 commits August 14, 2026 01:23
`allowedCallerPermissions` is an exact-match waiver the validator refuses
unless it carries a `write`, so a callee whose whole requested set is read
had no way to state what its callers must grant. `minimumCallerPermissions`
adds the missing floor: schema term, ordered comparison
(`none` < `read` < `write`) where a caller granting more passes and an
unnamed or omitted scope fails closed, and a load-time check that a contract
naming both terms is satisfiable. The waiver is untouched.

Backfilled onto the three `7107b34` gate contracts, each derived from the
callee's own `permissions:` block at that SHA: `pull-requests: read` and
`actions: read` on `semantic-pr`, `do-not-merge-gate`, and
`pr-issue-linkage`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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 checked against the declaration would admit exactly the
callers it exists to catch.

`minimumCallerPermissions` now rejects any non-read value at policy load, with
the reason named; a write obligation belongs in `allowedCallerPermissions`,
which is reviewed against the calling job rather than inferred from it. The
restriction is on what a contract may require, not on how grants compare: the
comparison stays ordered, so a caller granting write still clears a read floor,
as do `read-all` and `write-all` -- each still clears it after an event-time
downgrade lands at read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kyle-sexton
kyle-sexton force-pushed the feat/runner-policy-minimum-caller-permissions branch from 8a1f4a1 to 2429a22 Compare August 14, 2026 05:25
@kyle-sexton
kyle-sexton merged commit 536252a into main Aug 14, 2026
46 checks passed
@kyle-sexton
kyle-sexton deleted the feat/runner-policy-minimum-caller-permissions branch August 14, 2026 13:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

runner-policy: contracts cannot express a required minimum caller permission

1 participant