chore: harden dependency installs and CI execution paths - #15
Merged
Conversation
npm has no allow-list model for lifecycle scripts, so ignore-scripts is the blunt equivalent of pnpm's onlyBuiltDependencies: a trojanized dependency cannot execute on `npm ci` in CI or on a laptop, where live credentials sit next to the install. A full install and test pass under the setting confirms nothing here needs it — the one dependency with an install hook is esbuild, whose platform binary arrives through optionalDependencies and is resolved by the JS shim at runtime. The setting also suppresses this package's own prepublishOnly hook, so the release workflow now documents that the explicit build step is what produces dist/ and must stay ahead of any publish.
This job holds ANTHROPIC_API_KEY, and the action it consumed was referenced by a tag that its maintainers can move. The action's own latest release is the floating v1 tag with immutable releases not enabled, so a retag would have reached this workflow without anything in the file changing. Pinned to the commit v1 resolves to today, so behavior is unchanged.
Both checks block here, which this repo can afford: all 55 installed packages carry valid registry signatures, and its production dependencies have no known advisories. Holding that at zero is far cheaper than reclaiming it later. Dependabot's github-actions entry keeps the pinned action SHAs current; without it they silently rot.
Every job invoked its tools through npx. npx falls back to downloading a package when the name is not in the lockfile, so tools that were never declared arrived unpinned and unverified at run time — after the signature and advisory gates had already passed on the installed tree, and in jobs holding a provider key or a write token. tsx was one of those, and is now a declared devDependency so the eval jobs run the version the lockfile records. Every invocation takes --no-install so a tool that goes missing fails the job instead of quietly fetching a replacement. The remaining case was 'npx biome check .'. The Biome CLI is not a dependency of this project and never has been, so that line resolved an unrelated package of the same short name and ran its no-op CLI: the lint gate reported success without reading a single file, while pulling an unreviewed dependency tree into CI and release. Removing it costs no coverage that existed. Adopting @biomejs/biome properly, and fixing the diagnostics it reports, is a change of its own.
Both jobs that install the OpenProse skill took it from another repository's default branch. That skill becomes the instructions the model follows, and both jobs hold a credential — an API key with pull-request write access in one, a provider key in the other. A commit pushed to that repository would therefore change what runs here, with nothing reviewed on this side. Both now fetch a fixed commit and leave no git credential on disk. Bump the ref deliberately, the same way the action pins are maintained.
The full eval workflow interpolated its three dispatch inputs straight into a bash script running with a provider key in the environment. GitHub substitutes those values before bash parses the line, so an input carrying command substitution or a statement separator executed as code — a path to the key that needs no change to this repository. The inputs now travel through the step environment and are read as quoted variables, and each is checked before use: tier is a choice, concurrency must be a small positive integer, and a model override must look like a provider/name pair.
The release job runs on push to main, which is the path a change takes when it lands without a pull request. It ran none of the supply-chain checks that pull requests get, so the branch with the least review had the least verification. It now runs the same blocking signature and production advisory gates. It also held contents: write for the whole job, with a git credential left on disk, while every step in it only reads and builds. The write scope belongs to a release step that does not exist yet; when one is added, give it its own job rather than widening this one.
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.
Dependency installs and CI jobs both executed code this repository never reviewed. This closes those paths without changing what anything resolves to.
Install scripts
npm has no allow-list model for lifecycle scripts, so
.npmrcsetsignore-scripts=true— the blunt equivalent of pnpm'sonlyBuiltDependencies. A trojanized dependency cannot execute onnpm ci, in CI or on a laptop where live credentials sit beside the install. A full install and test pass under the setting confirms nothing here needs it: the only install hooks in the tree belong to esbuild, whose platform binary arrives throughoptionalDependenciesand is resolved by the JS shim at runtime.Registry integrity
npm audit signaturesandnpm audit --omit=devboth block, in pull-request CI and now on push as well — the release workflow runs on push tomain, which is the path a change takes when it lands without a pull request, and it previously ran none of these checks. This repository can afford both gates: every installed package carries a valid registry signature, and its production dependencies have no known advisories. Holding that at zero is cheaper than reclaiming it later.Tools now come from the lockfile
Every job invoked its tools through
npx, which downloads a package when the name is not in the lockfile — so undeclared tools arrived unpinned and unverified at run time, after the gates above had already passed on the installed tree, and in jobs holding a provider key or a write token.tsxis now a declared devDependency, and every invocation takes--no-installso a tool that goes missing fails the job instead of quietly fetching a replacement.The remaining case was
npx biome check .. The Biome CLI is not a dependency of this project and never has been, so that line resolved an unrelated package of the same short name and ran its no-op CLI: the lint gate reported success without reading a single file, while pulling an unreviewed dependency tree into CI and release. Removing it costs no coverage that existed. Adopting@biomejs/biomeproperly, and fixing the diagnostics it reports, is a change of its own —biome.jsonstays in place for editors and local use.Pinned references
anthropics/claude-code-actionis pinned to the commit itsv1tag pointed at when the pin was written (v1.0.186). That tag is movable, and it has moved since:v1now resolves to a different commit. Without the pin that retag would have changed what runs here with nothing in this repository changing — which is the whole argument for pinning, demonstrated inside a week.The two jobs that install the OpenProse skill took it from
openprose/prose's default branch. That skill becomes the instructions the model follows, and both jobs hold a credential — an API key with pull-request write access in one, a provider key in the other. Both now fetch a fixed commit and leave no git credential on disk.A monthly grouped Dependabot entry keeps the action pins from going stale.
Dispatch inputs
The full eval workflow interpolated its three dispatch inputs straight into a bash script running with a provider key in the environment. Those values are substituted before bash parses the line, so an input carrying command substitution or a statement separator executed as code. They now travel through the step environment as quoted variables, and each is validated before use: tier is a choice, concurrency must be a small positive integer, and a model override must look like a
provider/namepair.Release job scope
It held
contents: writefor the whole job, with a git credential on disk, while every step in it only reads and builds. It is nowcontents: readwith no persisted credential. The write scope belongs to a release step that does not exist yet; when one is added it should get its own job rather than widening this one.Note for anyone publishing by hand
ignore-scripts=truealso suppresses this package's ownprepublishOnlyhook, sonpm publishwill not builddist/for you. The explicitnpm run buildstep in the release workflow is what produces the artifact, and it must stay ahead of any publish step. Verified: from a clean tree, a publish dry-run under this setting yields a tarball containing onlyLICENSE,README.md, andpackage.json.