From 9950e386e2e5de701157e339e488c5e9847ceabf Mon Sep 17 00:00:00 2001 From: vivekchand Date: Sat, 5 Sep 2026 17:46:32 +0000 Subject: [PATCH] Harden CI: install the Claude Code CLI from a lockfile Three workflows installed the CLI with `npm install -g @anthropic-ai/claude-code` -- no version, no lockfile. Each run resolved the registry's moving `latest`, so the job took whatever the tag pointed at the moment it ran, and verified nothing about it. zizmor reports each as `adhoc-packages` (installs a package outside of a lockfile). None of the three is a bystander to what it installs: harness-observability-audit issues: write + CLAUDE_CODE_OAUTH_TOKEN i18n-autotranslate contents/PRs: write + CLAUDE_CODE_OAUTH_TOKEN i18n-docs-autotranslate contents/PRs: write + CLAUDE_CODE_OAUTH_TOKEN All three then run that binary over repository content, so an unverified tree executes with the job's token. Replaced with `npm ci` against a committed .github/claude-cli/package-lock.json, which installs exactly the recorded tree and checks every tarball against its integrity hash. A bare `@version` pin does neither -- it re-resolves transitive deps each run and verifies nothing. One shared manifest rather than three because all three consume the same CLI. Pinned 2.1.251 rather than latest (2.1.261): .github/dependabot.yml sets a 7-day cooldown on every ecosystem, on the reasoning that a compromised release is usually yanked within a day or two. 2.1.251 is the newest release that clears that window, so the initial pin honours the same rule the automated bumps do. The new npm entry for /.github/claude-cli keeps the lock moving rather than frozen -- the pin/updater pairing that file already documents for the SHA pins. The two i18n jobs set up Python but never Node, so they relied on whatever the runner image shipped. The CLI declares `engines: node >=22`, so both now pin setup-node 22, matching harness-observability-audit. Verified: - zizmor: 53 findings -> 50, and the three that went are exactly these `adhoc-packages`. No new finding appears. - `npm ci` from the committed lock in a clean directory exits 0 and `claude --version` reports 2.1.251. - All 36 workflow files and dependabot.yml parse under yaml.safe_load; both manifests parse as JSON. - scripts/check_action_refs.py: 17 references, all SHA-pinned. No-PRD: CI-only supply-chain hardening under .github/. No product behaviour, flag, default or endpoint changes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SGHzpWqT9vqseYSeaebJvw --- .github/claude-cli/package-lock.json | 152 ++++++++++++++++++ .github/claude-cli/package.json | 10 ++ .github/dependabot.yml | 17 ++ .../workflows/harness-observability-audit.yml | 19 ++- .github/workflows/i18n-autotranslate.yml | 32 +++- .github/workflows/i18n-docs-autotranslate.yml | 27 +++- 6 files changed, 249 insertions(+), 8 deletions(-) create mode 100644 .github/claude-cli/package-lock.json create mode 100644 .github/claude-cli/package.json diff --git a/.github/claude-cli/package-lock.json b/.github/claude-cli/package-lock.json new file mode 100644 index 0000000000..8ab85210e9 --- /dev/null +++ b/.github/claude-cli/package-lock.json @@ -0,0 +1,152 @@ +{ + "name": "clawmetry-ci-claude-cli", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "clawmetry-ci-claude-cli", + "version": "1.0.0", + "license": "UNLICENSED", + "devDependencies": { + "@anthropic-ai/claude-code": "2.1.251" + } + }, + "node_modules/@anthropic-ai/claude-code": { + "version": "2.1.251", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-2.1.251.tgz", + "integrity": "sha512-eG+ZPPpW2Dbmnntf1Fz9/T9ewS8I8SKfc1tcU2PqSwmftfjRPP7BXPaCyLuZ8kvgTdiPnJi/2/JnTvTRieneEQ==", + "dev": true, + "hasInstallScript": true, + "license": "SEE LICENSE IN README.md", + "bin": { + "claude": "bin/claude.exe" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "@anthropic-ai/claude-code-darwin-arm64": "2.1.251", + "@anthropic-ai/claude-code-darwin-x64": "2.1.251", + "@anthropic-ai/claude-code-linux-arm64": "2.1.251", + "@anthropic-ai/claude-code-linux-arm64-musl": "2.1.251", + "@anthropic-ai/claude-code-linux-x64": "2.1.251", + "@anthropic-ai/claude-code-linux-x64-musl": "2.1.251", + "@anthropic-ai/claude-code-win32-arm64": "2.1.251", + "@anthropic-ai/claude-code-win32-x64": "2.1.251" + } + }, + "node_modules/@anthropic-ai/claude-code-darwin-arm64": { + "version": "2.1.251", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-darwin-arm64/-/claude-code-darwin-arm64-2.1.251.tgz", + "integrity": "sha512-Qr5oMGVrOUyatsMlK0361OSnr3C785QBFIDoaiHMpaJ/nu/Ji2ccwI7nv0o54q3v3Y+zU9xbtEmcGxPRcR9ptA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-code-darwin-x64": { + "version": "2.1.251", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-darwin-x64/-/claude-code-darwin-x64-2.1.251.tgz", + "integrity": "sha512-wDMj6uELqU/uUeXH3E+32R9Gyx6pxdffBmjCbP8CoGGN0VufEd88vXhIGK2P0XIsksSh3fJZdxS00EaQSpHYPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-code-linux-arm64": { + "version": "2.1.251", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-linux-arm64/-/claude-code-linux-arm64-2.1.251.tgz", + "integrity": "sha512-h4EXRyV25yJfez40cTIoBlJptJqccP8nIDPdDMO9TDPUzZ6e5Y3OO9AgrfdMRAgccaTRsKfJenh7lA2D40nzdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-code-linux-arm64-musl": { + "version": "2.1.251", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-linux-arm64-musl/-/claude-code-linux-arm64-musl-2.1.251.tgz", + "integrity": "sha512-VJAZGuGTaRQINdUS35qhkgZoK8R+0MciPHMG6KMTFHjLC8GKtxj47RmiWcCUAu+fJpEOsko0FLEBqvAbdOpkJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-code-linux-x64": { + "version": "2.1.251", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-linux-x64/-/claude-code-linux-x64-2.1.251.tgz", + "integrity": "sha512-HJyCY1ynzlsBk+N02IJeBNNZmzyd43lMuff49IXtbUDGHlf2XFHcxwYJEWCwIW51J3Hl4MvrqM6Ye8PGpJRIiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-code-linux-x64-musl": { + "version": "2.1.251", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-linux-x64-musl/-/claude-code-linux-x64-musl-2.1.251.tgz", + "integrity": "sha512-fuELyER/KyDie/iPimqHgZauPINBb+mWoYG7dXKxaxXlqCSmSI9YHzDVQ/1zSfiUnAU3FhbT1p9GNBqwJPikqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-code-win32-arm64": { + "version": "2.1.251", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-win32-arm64/-/claude-code-win32-arm64-2.1.251.tgz", + "integrity": "sha512-6hkf7WoAk74WJuQ/epE+GKy5SJ7RU7kcpy3PRFHt6E1tiYNscihfZY1TMQ+AMQsDo1iFQFHbGITR95+vr3at+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/claude-code-win32-x64": { + "version": "2.1.251", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-win32-x64/-/claude-code-win32-x64-2.1.251.tgz", + "integrity": "sha512-fVXAvS2lCMJWD/lcyzzai5pcDQnlldGl8pwyGQ2vBxcuF8LS/7nVDqLOqTZsoAJ+VDKnlkPlPOJytDQEhTHHMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + } + } +} diff --git a/.github/claude-cli/package.json b/.github/claude-cli/package.json new file mode 100644 index 0000000000..1d8344ab6c --- /dev/null +++ b/.github/claude-cli/package.json @@ -0,0 +1,10 @@ +{ + "name": "clawmetry-ci-claude-cli", + "version": "1.0.0", + "private": true, + "description": "Pinned Claude Code CLI for the CI workflows that shell out to `claude`.", + "license": "UNLICENSED", + "devDependencies": { + "@anthropic-ai/claude-code": "2.1.251" + } +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 64aafef45c..632122c146 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -92,6 +92,23 @@ updates: cooldown: default-days: 7 + # npm — the Claude Code CLI that CI workflows shell out to + # (harness-observability-audit, i18n-autotranslate, i18n-docs-autotranslate). + # Those jobs install it with `npm ci` from a committed lockfile, so without an + # updater here the pin would simply freeze. This is the same pin/updater + # pairing described above for the SHA pins: the lock makes an upgrade + # deliberate, this entry keeps it from going stale. + - package-ecosystem: "npm" + directory: "/.github/claude-cli" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 3 + labels: + - "dependencies" + cooldown: + default-days: 7 + # npm — the ClawHub/OpenClaw plugin, which is published to npm # (openclaw.release.publishToNpm). No lockfile, so Dependabot updates the # manifest ranges. diff --git a/.github/workflows/harness-observability-audit.yml b/.github/workflows/harness-observability-audit.yml index 73e06d6d3a..fc60b91076 100644 --- a/.github/workflows/harness-observability-audit.yml +++ b/.github/workflows/harness-observability-audit.yml @@ -60,8 +60,23 @@ jobs: with: node-version: "22" - - name: Install Claude Code CLI - run: npm install -g @anthropic-ai/claude-code + # Installed from a committed lockfile rather than the registry's moving + # `latest`. This job holds `issues: write` and the CLAUDE_CODE_OAUTH_TOKEN + # secret, so whatever this step installs runs with both — and + # `npm install -g @anthropic-ai/claude-code` took whatever the tag pointed + # at the moment the job ran, which is the window a compromised release is + # published into. + # + # `npm ci` installs exactly the tree recorded in + # .github/claude-cli/package-lock.json and verifies every tarball against + # the integrity hash stored beside it. A bare `@version` pin does neither: + # it re-resolves transitive deps on each run and checks nothing. The npm + # entry in .github/dependabot.yml is what keeps the lock from going stale. + - name: Install Claude Code CLI (from lockfile) + working-directory: .github/claude-cli + run: | + npm ci --no-audit --no-fund --silent + echo "$PWD/node_modules/.bin" >> "$GITHUB_PATH" - name: Token visibility (presence only) run: | diff --git a/.github/workflows/i18n-autotranslate.yml b/.github/workflows/i18n-autotranslate.yml index dff2734975..74e636f7ea 100644 --- a/.github/workflows/i18n-autotranslate.yml +++ b/.github/workflows/i18n-autotranslate.yml @@ -44,8 +44,9 @@ jobs: # so it never reads this one. The script below only runs # `git show :`, a local object read needing no credential. # Left persisted, a `contents: write` token would instead sit - # readable in .git/config for the whole job, which installs a global - # npm package and runs the Claude Code CLI over repository content. + # readable in .git/config for the whole job, which installs a + # third-party npm tree and runs the Claude Code CLI over repository + # content. persist-credentials: false - name: Guard - require Claude Code token @@ -60,9 +61,32 @@ jobs: with: python-version: '3.11' - - name: Install Claude Code CLI + # The pinned CLI declares `engines: node >=22`, and this job previously + # relied on whatever Node the runner image happened to ship. Pinning the + # toolchain here keeps `npm ci` reproducible rather than image-dependent. + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 if: env.SKIP != '1' - run: npm install -g @anthropic-ai/claude-code + with: + node-version: "22" + + # Installed from a committed lockfile rather than the registry's moving + # `latest`. This job holds `contents: write`, `pull-requests: write` and + # the CLAUDE_CODE_OAUTH_TOKEN secret, so what this step installs runs with + # both — and `npm install -g @anthropic-ai/claude-code` took whatever the + # tag pointed at the moment the job ran, which is the window a compromised + # release is published into. + # + # `npm ci` installs exactly the tree recorded in + # .github/claude-cli/package-lock.json and verifies every tarball against + # the integrity hash stored beside it. A bare `@version` pin does neither: + # it re-resolves transitive deps on each run and checks nothing. The npm + # entry in .github/dependabot.yml is what keeps the lock from going stale. + - name: Install Claude Code CLI (from lockfile) + if: env.SKIP != '1' + working-directory: .github/claude-cli + run: | + npm ci --no-audit --no-fund --silent + echo "$PWD/node_modules/.bin" >> "$GITHUB_PATH" - name: Translate deltas (via Claude Code CLI, no API key) if: env.SKIP != '1' diff --git a/.github/workflows/i18n-docs-autotranslate.yml b/.github/workflows/i18n-docs-autotranslate.yml index 0ad544ba10..14c9691670 100644 --- a/.github/workflows/i18n-docs-autotranslate.yml +++ b/.github/workflows/i18n-docs-autotranslate.yml @@ -52,9 +52,32 @@ jobs: with: python-version: '3.11' - - name: Install Claude Code CLI + # The pinned CLI declares `engines: node >=22`, and this job previously + # relied on whatever Node the runner image happened to ship. Pinning the + # toolchain here keeps `npm ci` reproducible rather than image-dependent. + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 if: env.SKIP != '1' - run: npm install -g @anthropic-ai/claude-code + with: + node-version: "22" + + # Installed from a committed lockfile rather than the registry's moving + # `latest`. This job holds `contents: write`, `pull-requests: write` and + # the CLAUDE_CODE_OAUTH_TOKEN secret, so what this step installs runs with + # both — and `npm install -g @anthropic-ai/claude-code` took whatever the + # tag pointed at the moment the job ran, which is the window a compromised + # release is published into. + # + # `npm ci` installs exactly the tree recorded in + # .github/claude-cli/package-lock.json and verifies every tarball against + # the integrity hash stored beside it. A bare `@version` pin does neither: + # it re-resolves transitive deps on each run and checks nothing. The npm + # entry in .github/dependabot.yml is what keeps the lock from going stale. + - name: Install Claude Code CLI (from lockfile) + if: env.SKIP != '1' + working-directory: .github/claude-cli + run: | + npm ci --no-audit --no-fund --silent + echo "$PWD/node_modules/.bin" >> "$GITHUB_PATH" - name: Translate README into all locales if: env.SKIP != '1'