Harden CI: least-privilege token scopes for the last 3 workflows - #5253
Conversation
ci.yml, auto-deploy-cloud.yml and desktop-artifacts.yml were the last workflows with no top-level `permissions:` block, so each job ran on the repository's ambient GITHUB_TOKEN default. Declare a `contents: read` floor on all three and leave the one job that genuinely writes elevated at job level. - ci.yml: all 13 jobs checkout, install, test and upload an artifact. None calls the GitHub API with GITHUB_TOKEN, pushes, tags, comments or touches a Release, so the read-only floor covers the whole workflow and no job needs an elevated block. - auto-deploy-cloud.yml: every cross-repo action authenticates as secrets.CLOUD_REPO_PAT, which a workflow permissions block does not govern. Only the checkout of this repo rides on GITHUB_TOKEN, so `contents: read` is the complete requirement. - desktop-artifacts.yml: macos and linux inherit the read-only floor; windows already re-declares `id-token: write` + `contents: read` for azure/login; release keeps its job-level `contents: write` so action-gh-release can attach installers and publish the tag's Release. The elevation stays on that job rather than the workflow so the build jobs, and the third-party actions they run, cannot write to the repo. No-PRD: CI-only change under .github/, exempt from the product-record gate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016UY8n2YF46YMGvThcJPwvk
✅ Drift Bot (ClawMetry): no drift detectedDrift Bot analyzed the changed files against this project's blueprints and requirements and found no drift. |
✅ Drift Bot (ClawMetry): no drift detectedDrift Bot analyzed the changed files against this project's blueprints and requirements and found no drift. |
#5250 was expected to cover this file but landed without a top-level permissions block, leaving it the last workflow in the repo with none. Functionally inert by design: the workflow has exactly one job, that job declares its own contents/pull-requests/actions write scopes, and a job-level block REPLACES the top level rather than merging with it — so this value never applies to `release`. It is here so any job added later defaults to read-only instead of inheriting the repository default, and so the file no longer reads as unscoped. The publish, tag and PR scopes stay on the job that needs them. Verified: 34 workflows parse; no workflow is now missing a top-level permissions block; release job scopes unchanged (contents/pull-requests/actions: write); 158 workflow tests pass. No-PRD: CI-only change confined to .github/, exempt from the product-record gate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016UY8n2YF46YMGvThcJPwvk
|
✨ auto-fixed: branch was BEHIND main (base Generated by Claude Code |
✅ Drift Bot (ClawMetry): no drift detectedDrift Bot analyzed the changed files against this project's blueprints and requirements and found no drift. |
|
What failed
No test failed. Those 8 checks never got a runner: their jobs were created at 13:43:47 and are still Why it isn't this PR's
The gate's 30-minute budget is simply shorter than the queue latency the repo is seeing today. What I'm not doingNo fix ported, because there isn't one that belongs here. Raising I'm holding my single re-run rather than spending it now: a fresh poll started while ~20 runs are still queued ahead would just time out again. I'll re-run If the queue is being starved by the ~20 open dependabot PRs each running the full matrix, that's worth a look independently of this PR. Generated by Claude Code |
Two hardening PRs each added a top-level `permissions: contents: read` to this file -- 210696e put one below `concurrency:`, 85873f0 put another above it. Neither conflicted in git and both were individually right, but YAML forbids a repeated mapping key: GitHub rejected the workflow and failed every run at startup, with zero jobs, listed under the file path instead of "API Latency Smoke". The check has been off on main since 85873f0 landed, and the last real run was 2026-08-22. Keep the block above `concurrency:` and fold the removed comment's point about job-level elevation into it. The effective scope is unchanged: `contents: read`, one job, no job-level override. The pre-merge guard could not see this. `yaml.safe_load` resolves a duplicate key last-one-wins and raises nothing, so test_workflow_parses_as_yaml passed on a file GitHub refuses to run. Add test_workflow_has_no_duplicate_keys next to it, using a SafeLoader subclass whose mapping constructor rejects a repeat -- auto-discovered over every workflow, like the guards around it. Verified: 34 workflows parse and none repeats a key; every workflow still declares a top-level permissions block; api-latency-smoke resolves to contents: read with its single `smoke` job; 184 workflow tests pass; and re-inserting the duplicate fails the new test while the old parse test still passes -- the blind spot this closes. No-PRD: CI-only change under .github/ and tests/, exempt from the product-record gate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AFma7np7KETvSM5EyyenBJ
✅ Drift Bot (ClawMetry): no drift detectedDrift Bot analyzed the changed files against this project's blueprints and requirements and found no drift. |
|
Pushed Two earlier PRs in this series each added a top-level The fix keeps one block and folds the removed comment's point about job-level elevation into it. Effective scope is unchanged: The pre-merge guard could not catch it. Verified: 34 workflows parse and none repeats a key; every workflow still declares a top-level One thing worth a look: The earlier E2E Gate failure on this branch was a timeout — it waited 30 minutes for the CI run, which sat queued for 48. Generated by Claude Code |
|
Closing the loop on my note above: all 36 checks are green on No re-run was needed — the runner backlog drained on its own, the Nothing outstanding from my side. This is waiting on review. One thing worth flagging for whoever merges: Generated by Claude Code |
Closes out the TokenPermissions batch: after this, every workflow in
.github/workflows/declares exactly one top-levelpermissions:block — 34/34, verified on the current head with a loader that rejects duplicate keys.These were left for last because all of them touch a release or deploy path, where a blanket
contents: readbreaks the pipeline silently. Each job was read individually and the scope justified below rather than applied uniformly.ci.yml—contents: read, no job elevationAll 13 jobs do the same shape of work: checkout, install, run tests, upload an artifact. Nothing in the file calls the GitHub API with
GITHUB_TOKEN, pushes a commit, creates a tag, comments on a PR, or touches a Release. The onlyuses:beyond checkout/setup areactions/cache,actions/upload-artifactand the local./.github/actions/setup-openclaw;upload-artifactuses the runtime token, notGITHUB_TOKEN. The read-only floor therefore covers the whole workflow and no job needs an elevated block.auto-deploy-cloud.yml—contents: readWorth stating explicitly because the job name suggests otherwise: this workflow checks out
clawmetry-cloud, pushes a branch, and creates, closes and merges a PR there — but every one of those steps authenticates assecrets.CLOUD_REPO_PAT, both as thetoken:on the second checkout and asGH_TOKENon theghsteps. A workflowpermissions:block does not govern a PAT, so none of that cross-repo work is affected by this change.The only thing riding on
GITHUB_TOKENis the checkout of this repo, which needscontents: readand nothing else. Keeping the floor read-only means a future step added here cannot quietly pick up write access toclawmetrythrough the ambient token — it would have to ask for the PAT, which is visible in review.desktop-artifacts.yml—contents: readfloor,releasestayscontents: writemacoscontents: readlinuxcontents: readwindowsid-token: write,contents: readazure/loginOIDCreleasecontents: writesoftprops/action-gh-releaseattaches installers to the tag's Release and publishes itThe elevation deliberately stays on the
releasejob. A workflow-widecontents: writewould hand it to all three build jobs and to every third-party action they run, which on the signing paths is the largest surface in this repo.Note the ordering hazard this file already handles correctly: a job-level
permissions:block replaces the top level rather than merging with it, so thewindowsjob's existing block had to already carrycontents: readfor its checkout to keep working. It does (with a comment saying exactly that), so the new top-level block does not regress it.release-on-merge.yml—contents: readfloor, job scopes untouched#5250 was expected to cover this file but landed without a top-level block, leaving it the last workflow in the repo with none — caught by re-running the check across all 34 workflows after merging
maininto this branch, not assumed from #5250's description.The change is functionally inert by design. The workflow has exactly one job; that job declares its own
contents: write/pull-requests: write/actions: write; and since a job-level block replaces the top level, the new value can never apply torelease. Its purpose is that a job added here later defaults to read-only rather than inheriting the repository default, and that the file stops reading as unscoped. The publish, tag and PR-merge scopes stay on the job that needs them.api-latency-smoke.yml— repairing a workflow the batch had brokenCarried in
36e773d. Two earlier hardening commits each added a top-levelpermissions: contents: readto this file —210696ebelowconcurrency:,85873f0(#5250) above it. Neither conflicted in git and both were individually correct, but YAML forbids a repeated mapping key, so GitHub rejected the workflow and failed every run at startup with zero jobs. The check had been dead onmainsince #5250 landed.The fix keeps the block above
concurrency:and folds the other comment's point into it. Effective scope is unchanged:contents: read, one job, no job-level override.Why the existing guard missed it, and the correction
This is worth flagging because it undercuts how the earlier revisions of this PR described their own verification.
yaml.safe_loadresolves a duplicate key last-one-wins and raises nothing — sotest_workflow_parses_as_yaml, and the "all 34 workflows parse" check cited in this PR's earlier description, both passed happily on a file GitHub refuses to run. Parsing cleanly was never evidence of a singlepermissions:block; it just could not see the second one.36e773daddstests/test_workflow_has_no_duplicate_keysnext to the existing guard, using aSafeLoadersubclass whose mapping constructor rejects a repeat, auto-discovered over every workflow. Re-inserting the duplicate fails the new test while the old parse test still passes — which is precisely the blind spot being closed.Verification
Re-run on the current head (
36e773d), which includesmain(bringing #5250/#5251/#5252):safe_load.permissions:block; each of the four files this PR touches has exactly one.release-on-merge's job scopes confirmed unchanged.api-latency-smokeresolves tocontents: readwith its singlesmokejob.scripts/check_action_refs.py— 17 action references, OK.No functional change beyond reviving
api-latency-smoke.yml: additivepermissions:blocks and one de-duplication, no step, trigger or job logic touched.No-PRD: CI-only change confined to
.github/andtests/, exempt from the product-record gate.