Uh oh!
There was an error while loading. Please reload this page.
sec(2157): scope the two board crons to what they actually do - #294
Conversation
First tier of backend#2157, taken on the two workflows whose requirement is DERIVABLE from what they run and whose result is VERIFIABLE by dispatch -- not on the ones whose scopes are still unmeasured. kanban-columns runs `kanban-columns-check.py`, which READS the project field vocabulary and writes nothing: `organization-projects: read` is the whole requirement. kanban-archive mutation surface is exactly `archiveProjectV2Item`: `organization-projects: write`, no issues, no pull-requests, no contents. Both previously minted the App FULL install -- contents+PR write across all 19 repos -- for a read of one project field list and one archive call. `repositories:` narrows the repo-level half. It does not touch the board: `organization_projects` is an ORG permission, measured unaffected by repo scoping in backend#2181 run 32255581084. That was the load-bearing unknown and it is already settled, so this does not re-litigate it. Both exemption rows come out of mint-scope.py in the same commit, so the guard now reports 10 unscoped rather than 12 and has no exemption for a workflow that no longer needs one -- a stale exemption is a licence nobody is using, which the guard itself reports. Deliberately NOT in this PR: the six caller-triggered board writers and the three protection readers. A narrower token on a protection read returns LESS DATA rather than erroring, so those need their own measurement window, not a plausible-looking guess. backend#2157 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
saadqbal
left a comment
There was a problem hiding this comment.
Approving. All fourteen checks green including Bugbot, zero open threads, MERGEABLE.
I verified the derivation rather than the description.kanban-columns-check.py is 175 lines making exactly one GraphQL call — query{node(id:"…"){… on ProjectV2{field(name:"Status")… — with no mutation, no updateProjectV2*, and no POST/PATCH/PUT/DELETE anywhere. So organization-projects: read is both sufficient and exact, and "every other permission the App holds was surplus" is literally true: it was minting contents + PR write across 19 repos to read one field's option list.
Removing both entries from mint-scope.py in the same PR is the part I'd have asked about if it weren't there — an exemption that outlives its reason is a standing licence, and dropping them means the checker now enforces on these two rather than waving them through.
Verifying by dispatch before opening, and citing the run plus the specific job that constitutes the evidence (Written Status names exist on the board — the live board read passing on read alone), is the right standard. So is declining to re-litigate the repositories:-vs-org-permission question on the strength of backend#2181's measurement instead of re-deriving it.
One gap in that evidence, and it's small because of how the inputs compose. Both workflows' real trigger is schedule — 0 5 * * * and 15 6 * * * — and both dispatch runs were workflow_dispatch, which is a different event payload. repositories: ${{ github.event.repository.name }} is new here; kanban-reconcile.yml is the repo's other cron and doesn't use the idiom, so there's no in-repo precedent for it resolving on a scheduled run.
I don't think this is a defect, and here's why the downside is bounded: repositories: and permission-organization-projects: are independent inputs to the minting step. If that expression ever came back empty, the token would cover all installation repos while holding onlyorganization-projects — which has no repo-level effect at all. So the change that matters (dropping contents and PR write) lands unconditionally, and only the belt-and-braces repo narrowing would be lost. Worth a glance at the first scheduled run's minted scope to close it out, given this PR's own standard is measured-not-inferred and the trigger it will actually run on is the one not yet exercised.
The tiering is the right call too — taking only the two workflows whose requirement is derivable from what they run and verifiable by dispatch, and explicitly leaving the ones whose scopes are still unmeasured. That's what stops a least-privilege sweep from becoming a guessing exercise across nineteen repos.
Uh oh!
There was an error while loading. Please reload this page.
LukasWodka
commented
Aug 21, 2026
/fr-pass |
First tier of backend#2157, taken on the two workflows whose requirement is
derivable from what they run and whose result is verifiable by dispatch — not
on the ones whose scopes are still unmeasured.
What they actually needed
kanban-columns.ymlkanban-columns-check.py, which reads the project's field vocabulary and writes nothingorganization-projects: **read**kanban-archive.ymlarchiveProjectV2Itemorganization-projects: **write**Both previously minted the App's full install — contents + PR write across all 19
repos — for a read of one field list and one archive call.
repositories:narrows the repo-level half. It does not touch the board:organization_projectsis an org permission, measured unaffected by repo scoping inbackend#2181's run 32255581084. That was the load-bearing unknown and it is already
settled, so this PR does not re-litigate it.
Verified by dispatch on this branch, not inferred
Both dispatched from
sec/2157-scope-the-board-cronsbefore opening this PR:run 32404102201 —
kanban-columns, success. The job that matters isWritten Status names exist on the board, which is the live board read: it passed withorganization-projects: readalone.run 32404104720 —
kanban-archivewithdry-run=true, success, and the log is the evidence:The narrowed token resolved the project id and enumerated all 65 items. Dry-run
exercises mint + resolve + enumerate; the archive mutation itself is the same
organization_projectsgrant, and the first cron run is its proof.The guard moves with the change
Both rows come out of
mint-scope.py'sEXEMPTin the same commit, so it now reports10 unscoped / 10 exempted instead of 12/12. Leaving an exemption for a workflow
that no longer needs one is a licence nobody is using — which that guard reports as
its own finding (
stale_exemptions).Deliberately not in this PR
The six caller-triggered board writers (
advance-deploy-env,set-pr-status,kanban-closure-router,fr-pass-comment,customer-priority-bump,kanban-reconcile) and the three protection readers (bricked-prs,merge-settings-drift,standards-sync) plusfr-gate.The protection readers are the reason for splitting: a narrower token on a branch-protection
read returns less data rather than erroring, so a wrong guess there does not fail —
it quietly reports a cleaner fleet than exists. That needs its own measurement window,
not a plausible-looking guess shipped alongside two easy ones.
customer-priority-bumpis interesting and nearly free — its whole surface is onegh issue edit --add-label, i.e.issues: writeand no board access at all — butit is
workflow_call-triggered, so verifying it means labelling a real issue. Nexttier, with a verification plan.
Test plan
actionlinton both workflowspython3 scripts/mint-scope.py→ 0 findings, no stale exemptionspython3 scripts/tests/mint-scope-selftest.py→ 13/13make check→ 41/41🤖 Generated with Claude Code
Note
Medium Risk
Touches GitHub App token minting for live org ProjectV2 jobs. Wrong scopes would break board archive/conformance, but this is a privilege reduction, not a grant expansion.
Overview
Scopes the GitHub App installation tokens used by
kanban-columns.ymlandkanban-archive.ymlinstead of minting the App’s full install grant.kanban-columnsnow requestsorganization-projects: read(read-only field check).kanban-archiverequestsorganization-projects: write(thearchiveProjectV2Itemmutation). Both also setrepositories:to this repo so leftover repo-level grants are not fleet-wide.Removes those two workflows from
mint-scope.pyEXEMPTso the guard treats them as scoped and will flag a stale exemption if they regress. Other board writers and protection readers stay exempt.Reviewed by Cursor Bugbot for commit 2e97a08. Bugbot is set up for automated code reviews on this repo. Configure here.