Ci: restrict GITHUB_TOKEN to contents: read in the three read-only CI workflows - #649
Open
AmaadMartin wants to merge 2 commits into
Open
Ci: restrict GITHUB_TOKEN to contents: read in the three read-only CI workflows#649AmaadMartin wants to merge 2 commits into
AmaadMartin wants to merge 2 commits into
Conversation
added 2 commits
August 4, 2026 11:18
validation.yaml, cross-language-integration.yml and license-check.yml declared no top-level permissions:, so their GITHUB_TOKEN inherited the repository default, which on older repositories is read/write on every scope. All three only read the repository: they checkout, install, build and test, and no step pushes, uploads an artifact, comments on an issue or PR, or publishes a release asset. A top-level permissions: block is a complete specification - every scope not listed is reset to none - so contents: read grants exactly repository read and nothing else, and any job later added to these files inherits the same floor. Placement follows the existing csat.yml and auto-assignment.yml pattern (between on: and jobs:); validation.yaml also has an env: block, so the order there is on: -> permissions: -> env: -> jobs:. csat.yml, auto-assignment.yml and release-please.yml already declare permissions and are deliberately untouched - release-please genuinely needs contents: write.
With persist-credentials left at its default the checkout writes an http.extraheader credential into .git/config, leaving a usable token in the workspace for every later step in the job - including npm install, which runs lifecycle scripts from the whole transitive dependency tree. Nothing in these three jobs needs it: package.json and package-lock.json contain no git+https, git+ssh or github: specifiers, no source under core/, dev/, integrations/, tests/ or scripts/ shells out to git, and the two go.mod files under tests/cross_language/a2a/ list only public modules resolved through the Go module proxy. persist-credentials: false does not affect the checkout's own fetch, which still authenticates normally. No uses: line is touched; pinning actions to commit SHAs is a separate concern.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
N/A — no existing issue.
Problem: Three of the six workflows in
.github/workflows/declare no top-levelpermissions:key, so theGITHUB_TOKENissued to their runs silently inherits the repository/organization default — which on older repositories is read/write on every scope:permissions:beforevalidation.yamlnpm install,npx secretlint,npm run build,npm run test:coverage,npm run lint,npm run format:check,npm run docs:checkcross-language-integration.ymlnpm install,go mod tidy×2,npm run build,npm run test:cross-languagelicense-check.ymlbash scripts/check_license.shcsat.ymlcontents: read,issues: write,pull-requests: writeauto-assignment.ymlcontents: read,issues: write,pull-requests: writerelease-please.ymlcontents: write,issues: write,pull-requests: writeI audited the first three step by step: not one step writes anything back to GitHub — no
git push, no artifact or coverage upload, no issue/PR comment, no release asset.scripts/check_license.shis a purefind+perlread that prints missing headers and exits 1. They are read-build-test workflows running with a token that may be able to push tomain.The blast radius is concrete rather than theoretical:
validation.yamlandcross-language-integration.ymlboth runnpm install, which executes lifecycle scripts from the entire transitive dependency tree, in the same job as that token.Solution: Two additions per file, 15 added lines across 3 files, 0 deleted lines.
permissions: contents: readat the top level of all three workflows. A top-levelpermissions:block is a complete specification — every scope not listed is reset tonone, it does not stay at the default — so this grants exactly "may read the repository, may do nothing else" (GitHub additionally grants an implicit, non-removableMetadata: read), and any job later added to these files inherits the same floor. Placement follows the existing in-repo pattern incsat.yml(lines 8-11) andauto-assignment.yml(lines 9-12), i.e. betweenon:andjobs:.validation.yamlis the one file with anenv:block, so the key order there ison:→permissions:→env:→jobs:.persist-credentials: falseon eachactions/checkoutstep. By default the checkout writes anhttp.extraheadercredential into.git/config, leaving a usable token in the workspace for every subsequent step in the job. Nothing in these jobs needs it, and I verified each claim against the repo rather than assuming:package.jsonandpackage-lock.jsoncontain zerogit+https,git+sshorgithub:specifiers (grep -cE '"resolved": "git\+' package-lock.json→0), sonpm installnever performs an authenticated git fetch.core/,dev/,integrations/,tests/orscripts/shells out togit.go.modfiles undertests/cross_language/a2a/list only public modules with noreplacedirectives, resolved through the public Go module proxy.persist-credentials: falsedoes not affect the checkout's own fetch — that still authenticates normally. It only stops the action from leaving the credential behind afterwards.Deliberately out of scope, so each concern stays independently reviewable and revertible: no
uses:line is modified (SHA-pinning and action major bumps are separate), no job/step/trigger/matrix/runs-on/env:value changes, and the scopes oncsat.yml/auto-assignment.yml/release-please.ymlare left alone. No extra scope was added "just in case" —contents: readand nothing more.Collision check (before implementing): enumerated all 548 open PRs on the fork; 46 touch
.github/workflows/. I diffed every one of those 46 and none adds apermissions:block orpersist-credentials:to any workflow, so this is not a duplicate. Several are textually adjacent to the same files but address different concerns and different lines — SHA-pinning (#505, #613),concurrencygroups (#504),timeout-minutes(#403), Node pinning /npm ci/ npm cache (#406, #415, #416, #428, #467, #508, #509, #571, #572, #574, #593 and others). This PR is branched frommainrather than stacked, because it shares no line with any of them.Testing Plan
Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.
Unit Tests:
[x] I have added or updated unit tests for my change. — N/A, and deliberately so. This change adds no executable line to any package: it is four YAML keys across three GitHub Actions configuration files. There is no module to import and no branch to exercise. A test that reads a workflow file and asserts on its own literal contents would be a tautology — it would pass equally with and against the change's intent. Verification is provided by the shape check and the live workflow run below, which exercise the real artifact. Nothing under
core/,dev/,integrations/,tests/orscripts/is touched, so no existing test is affected.[x] All unit tests pass locally. — no test-bearing code changed; the repo's own gates do not read YAML (
npm run format:checkisprettier "**/*.ts" --checkandnpm run lintiseslint "**/*.ts", so neither touches.yaml/.yml, and there is no reformatting risk).Static parse and shape check — run from the repository root, prints
OKthree times:Proving the check can fail (mutation testing). A check that passes with and without the change is a green light with no signal, so I ran it against four mutations. All four failed:
git archive HEAD~2 .github)KeyError: 'permissions'contents: read→contents: writeinvalidation.yamlAssertionError: ('.github/workflows/validation.yaml', {'contents': 'write'})persist-credentials: false→trueinlicense-check.ymlAssertionError: ('.github/workflows/license-check.yml', {'name': 'Checkout repository', 'uses': 'actions/checkout@v6', 'with': {'persist-credentials': True}})with:block from thecross-language-integration.ymlcheckoutAssertionError: ('.github/workflows/cross-language-integration.yml', {'name': 'Checkout code', 'uses': 'actions/checkout@v6'})Diff shape check —
git diff --statagainst the base:Exactly three files, 15 insertions, 0 deletions, and
git diff -U0 | grep -cE '^[+-].*uses:'→0, confirming nouses:line was touched and nothing was reformatted.Resulting
permissions:across all six workflows (the three untouched files are unchanged):actionlintwas attempted (npx --yes actionlint .github/workflows/*) but could not be fetched in this environment; it is not a gate in this repo.Manual End-to-End (E2E) Tests:
This pull request is itself the E2E run. All three workflows trigger on
pull_request: branches: [main], and GitHub evaluates the workflow definition from the PR's merge ref rather than from the base branch, so opening this PR exercises all three files with the newpermissions:block in effect. No separate scratch PR is needed.To reproduce:
validation(ubuntu, windows and macos legs),Cross-Language Tests, andLicense Header Check. A missing check would mean a YAML parse failure.GITHUB_TOKEN Permissionssection. It now listsContents: read(plusMetadata: read, which GitHub always grants implicitly and which nopermissions:block can remove) — the direct evidence that the change did what it claims, which a diff alone cannot show.Result of running exactly that on this PR. All three workflows appeared in the checks list, so all three parsed.
GITHUB_TOKEN Permissionsfrom the "Set up job" step of one run of each:GITHUB_TOKEN Permissionsvalidation.yamlrun-tests (ubuntu-latest)Contents: read,Metadata: readpersist-credentials: falsecross-language-integration.ymlrun-testsContents: read,Metadata: readpersist-credentials: falselicense-check.ymlcheck-licenseContents: read,Metadata: readpersist-credentials: falseBefore/after. For contrast, here is the same "Set up job" section from a
License Header Checkrun on another branch that does not carry this change — the inherited default this PR replaces:19 scopes, 17 of them
writeincludingContents: write, reduced toContents: read. Every step still passed, so no step needed any of the scopes that were dropped.persist-credentials: falseverified empirically too. Grepping all three workflows' full run logs forcould not read Username,Resource not accessible by integration,HTTP 403,fatal: Authentication, andremote: Permissionreturns zero hits. The riskiest step isgo mod tidyincross-language-integration.yml, since its module paths aregithub.com/...; it resolved every module through the public Go proxy with no credential (go: downloading google.golang.org/adk v1.0.0,github.com/google/go-cmp v0.7.0, …) andnpm run test:cross-languagethen reportedTest Files 2 passed (2).npm installlikewise completed on all four legs.Checks result.
License Header Check,Cross-Language Tests, andvalidationon ubuntu and windows all passed on the first attempt. The macOSvalidationleg failed on two pre-existing, timing-sensitive integration timeouts unrelated to this change:This is a pre-existing macOS flake, not a consequence of this change, and I checked rather than assumed:
could not read Username,Resource not accessible by integration,HTTP 403andfatal: Authenticationreturns zero hits, and the job had already completed checkout,npm install,npx secretlintandnpm run buildsuccessfully before reaching the tests.grep -licE '\bgit\b|GITHUB_TOKEN|persist-credentials|\.github/workflows'over bothapp_loader_test.tsandbuild_setup_test.tsreturns 0 — neither reads git, the token, or a workflow file.validationruns on unrelated branches, the macOS leg failed onapp_loader_test.tson attempts 1-4 offix/a2a-agent-card-invocation-context(green on attempt 5) and on attempts 1-3 offix/tool-test-type-checker-suppressions(green on attempt 4). The test's budget isTEST_EXECUTION_TIMEOUT = 40000, which the macOS runner sits right on the edge of.Re-running the leg per that evidence, rather than attributing it to this change or "fixing" it here — that would break scope. It went green on the third attempt, matching the pattern above. Final state: all six checks green —
check-license,run-tests(Cross-Language), andrun-testson ubuntu-latest, windows-latest and macos-latest.How to read a red run, should one appear later:
contents: read. Add only that one scope to only that one workflow with a comment; do not delete thepermissions:block.fatal: could not read Username, a failedgit fetch, or npm resolving a git URL) points atpersist-credentials: false, not at the permissions block. Remove that one line from that one workflow and keep everything else.Checklist
[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas. — the rationale is in the two commit messages and this description; no inline comments were added, since a bare
permissions: contents: readis self-documenting and the repo's existing blocks carry no comment either.[x] I have added tests that prove my fix is effective or that my feature works. — see the shape check, its four mutation proofs, and the live workflow run above; a unit test is not applicable for a change that adds no executable line.
[x] New and existing unit tests pass locally with my changes.