From 8c41649e679cee545a08209f9366a7d118b4943c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 12:40:05 +0000 Subject: [PATCH 1/3] Harden CI: least-privilege token scopes for the last 3 workflows 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 Claude-Session: https://claude.ai/code/session_016UY8n2YF46YMGvThcJPwvk --- .github/workflows/auto-deploy-cloud.yml | 9 +++++++++ .github/workflows/ci.yml | 7 +++++++ .github/workflows/desktop-artifacts.yml | 9 +++++++++ 3 files changed, 25 insertions(+) diff --git a/.github/workflows/auto-deploy-cloud.yml b/.github/workflows/auto-deploy-cloud.yml index 28d3580f35..f402fea9fe 100644 --- a/.github/workflows/auto-deploy-cloud.yml +++ b/.github/workflows/auto-deploy-cloud.yml @@ -20,6 +20,15 @@ on: types: [completed] workflow_dispatch: +# Every cross-repo action below (checkout of clawmetry-cloud, branch push, PR +# create/close/merge) authenticates as `secrets.CLOUD_REPO_PAT`, which this +# block does not govern — a PAT carries its own scopes. The only thing that +# rides on GITHUB_TOKEN is the checkout of THIS repo, which needs `contents: +# read` and nothing more. Deliberately kept read-only so a future step cannot +# quietly acquire write on clawmetry via the ambient token. +permissions: + contents: read + jobs: pin-version: name: Rebuild cloud with latest OSS diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 975fefab8c..bac4b551f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,13 @@ on: branches: [main, master] pull_request: +# Every job here only reads the repository: checkout, install, run tests, +# upload an artifact. Nothing calls the GitHub API with GITHUB_TOKEN, pushes +# a commit, tags, comments on a PR or touches a Release, so the whole +# workflow runs on the read-only default and no job needs an elevated block. +permissions: + contents: read + concurrency: group: ci-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true diff --git a/.github/workflows/desktop-artifacts.yml b/.github/workflows/desktop-artifacts.yml index d92fa897b2..8d62c9a0e6 100644 --- a/.github/workflows/desktop-artifacts.yml +++ b/.github/workflows/desktop-artifacts.yml @@ -18,6 +18,15 @@ on: - 'v*.*.*' workflow_dispatch: {} +# Least-privilege floor for the three BUILD jobs (macos / linux inherit this; +# windows re-declares it alongside id-token). The `release` job, and only that +# job, elevates to `contents: write` so softprops/action-gh-release can attach +# installers to the tag's Release and publish it. Keep the elevation on that +# job — a workflow-wide `contents: write` would hand every build job, and each +# third-party action it runs, the ability to write to the repository. +permissions: + contents: read + jobs: macos: name: macOS .app + .dmg (signed + notarized when secrets are set) From 90f0abeaad5fc4e18c4e5195e897ac1e704b258f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 12:55:05 +0000 Subject: [PATCH 2/3] Add the read-only floor release-on-merge.yml was still missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 Claude-Session: https://claude.ai/code/session_016UY8n2YF46YMGvThcJPwvk --- .github/workflows/release-on-merge.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/release-on-merge.yml b/.github/workflows/release-on-merge.yml index b2a2303f00..81b3ec1627 100644 --- a/.github/workflows/release-on-merge.yml +++ b/.github/workflows/release-on-merge.yml @@ -5,6 +5,15 @@ on: types: [closed] branches: [main] +# Read-only floor. This workflow's single job re-declares its own scopes +# below, and a job-level block REPLACES the top level rather than merging +# with it, so this value never actually applies to `release` — it is here so +# the default for any job added later is read-only, and so the workflow +# stops reading as "inherits whatever the repository default happens to be". +# The publish/tag/PR scopes stay on the job that needs them, not up here. +permissions: + contents: read + jobs: release: name: Bump version & publish to PyPI From 36e773de646d9394de781df07794d37098894118 Mon Sep 17 00:00:00 2001 From: Vivek Chand Date: Wed, 26 Aug 2026 14:38:21 +0000 Subject: [PATCH 3/3] Revive api-latency-smoke.yml: one permissions block, not two 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 Claude-Session: https://claude.ai/code/session_01AFma7np7KETvSM5EyyenBJ --- .github/workflows/api-latency-smoke.yml | 6 +-- tests/test_workflow_yaml_valid.py | 52 +++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/.github/workflows/api-latency-smoke.yml b/.github/workflows/api-latency-smoke.yml index b13205fa78..f978db4b9b 100644 --- a/.github/workflows/api-latency-smoke.yml +++ b/.github/workflows/api-latency-smoke.yml @@ -14,6 +14,7 @@ on: # Least privilege: this workflow only builds a wheel and probes a local # dashboard. It never writes to the repository or any GitHub API surface. +# Any job needing more must elevate with its own job-level `permissions:`. permissions: contents: read @@ -21,11 +22,6 @@ concurrency: group: api-latency-smoke-${{ github.ref }} cancel-in-progress: true -# Least privilege: these jobs only read the repo. Any job needing more -# must elevate with its own job-level `permissions:` block. -permissions: - contents: read - jobs: smoke: name: ${{ matrix.source == 'pypi' && 'PyPI nightly' || 'PR build' }} diff --git a/tests/test_workflow_yaml_valid.py b/tests/test_workflow_yaml_valid.py index 8b1a6e646f..0347a2d191 100644 --- a/tests/test_workflow_yaml_valid.py +++ b/tests/test_workflow_yaml_valid.py @@ -79,6 +79,58 @@ def test_workflow_parses_as_yaml(path: str) -> None: ) +class _DuplicateKeyLoader(yaml.SafeLoader): + """A SafeLoader that refuses a mapping with a repeated key. + + PyYAML's own resolution is last-one-wins, silently: ``yaml.safe_load`` on a + file with two top-level ``permissions:`` blocks returns a perfectly good + dict and raises nothing. GitHub Actions does the opposite -- it rejects the + file outright and fails the run at startup -- so a duplicate key is exactly + the class of break ``test_workflow_parses_as_yaml`` cannot see. + """ + + +def _no_duplicate_keys(loader, node, deep=False): + seen = set() + for key_node, _ in node.value: + key = loader.construct_object(key_node, deep=deep) + if key in seen: + raise yaml.YAMLError( + f"duplicate key {key!r} at line {key_node.start_mark.line + 1}" + ) + seen.add(key) + return yaml.SafeLoader.construct_mapping(loader, node, deep=deep) + + +_DuplicateKeyLoader.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _no_duplicate_keys +) + + +@pytest.mark.parametrize("path", _workflow_files(), ids=os.path.basename) +def test_workflow_has_no_duplicate_keys(path: str) -> None: + """No mapping may repeat a key. GitHub rejects the file if one does. + + Burned by the token-permissions hardening pass: two separate PRs each + added a top-level ``permissions: contents: read`` block to + api-latency-smoke.yml. Both were individually correct, neither conflicted + in git, and the pre-merge check was ``yaml.safe_load`` -- which happily + kept the second and returned a valid dict. The workflow was dead on main + from the moment the second one merged, its runs listed under the file path + with zero jobs, and the API-latency safety net was off. + """ + with open(path, encoding="utf-8") as fh: + source = fh.read() + try: + yaml.load(source, _DuplicateKeyLoader) + except yaml.YAMLError as exc: # pragma: no cover - message is the point + pytest.fail( + f"{os.path.basename(path)} repeats a mapping key ({exc}). PyYAML " + "keeps the last one without complaining, but GitHub rejects the " + "workflow and fails every run at startup before any step executes." + ) + + @pytest.mark.parametrize("path", _workflow_files(), ids=os.path.basename) def test_workflow_has_required_top_level_keys(path: str) -> None: """A parseable file can still be a non-workflow. Check the shape."""