Skip to content

chore: harden dependency installs and CI execution paths - #15

Merged
josemontesdeoca merged 7 commits into
mainfrom
supply-chain-hardening
Aug 11, 2026
Merged

chore: harden dependency installs and CI execution paths#15
josemontesdeoca merged 7 commits into
mainfrom
supply-chain-hardening

Conversation

@josemontesdeoca

@josemontesdeoca josemontesdeoca commented Aug 7, 2026

Copy link
Copy Markdown
Member

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 .npmrc sets ignore-scripts=true — 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 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 through optionalDependencies and is resolved by the JS shim at runtime.

Registry integrity

npm audit signatures and npm audit --omit=dev both block, in pull-request CI and now on push as well — the release workflow runs on push to main, 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. tsx is now a declared devDependency, and 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 — biome.json stays in place for editors and local use.

Pinned references

anthropics/claude-code-action is pinned to the commit its v1 tag pointed at when the pin was written (v1.0.186). That tag is movable, and it has moved since: v1 now 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/name pair.

Release job scope

It held contents: write for the whole job, with a git credential on disk, while every step in it only reads and builds. It is now contents: read with 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=true also suppresses this package's own prepublishOnly hook, so npm publish will not build dist/ for you. The explicit npm run build step 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 only LICENSE, README.md, and package.json.

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.
@josemontesdeoca josemontesdeoca changed the title chore: block install scripts and gate CI on registry signatures chore: harden dependency installs and CI execution paths Aug 11, 2026
@josemontesdeoca josemontesdeoca self-assigned this Aug 11, 2026
@josemontesdeoca
josemontesdeoca merged commit 81e85f1 into main Aug 11, 2026
7 checks passed
@josemontesdeoca
josemontesdeoca deleted the supply-chain-hardening branch August 11, 2026 19:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant