skills/harden-npm/SKILL.md
--- name: harden-npm description: Apply npm/pnpm/bun supply-chain hardening to a repo. Pins packageManager, writes per-repo .npmrc with minimum-release-age + ignore-scripts, audits GitHub Actions for pull_request_target, installs husky pre-push hook if missing, optionally runs pnpm approve-builds. Idempotent and safe to re-run. Auto-invoked by /ro:new-tanstack-app and /ro:new-app. Use after any /ro:migrate-* or whenever a repo needs supply-chain controls brought up to canon. category: quality ar
npx skillsauth add RonanCodes/ronan-skills skills/harden-npmInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Apply supply-chain hardening defaults to a JS/TS repo. The defensive set codified after the Mini Shai-Hulud v2 (TanStack) attack, CVE-2026-45321.
/ro:new-tanstack-app or /ro:new-app (auto-invoked)/ro:migrate-to-tanstack or /ro:migrate-to-astro (any framework migration)/ro:harden-npm # apply to cwd
/ro:harden-npm /path/to/repo # apply to a specific repo
/ro:harden-npm --check # audit only, no writes (report what would change)
/ro:harden-npm --no-husky # skip husky pre-push step
/ro:harden-npm --no-approve-builds # skip the interactive pnpm approve-builds walk
Six concrete changes. All idempotent. All skip-if-already-applied.
| # | Action | Why |
|---|---|---|
| 1 | Upgrade pnpm to v11+ via corepack (global), pin packageManager in package.json | pnpm 11 ships minimumReleaseAge=1440, strictDepBuilds=true, blockExoticSubdeps=true as defaults |
| 2 | Write per-repo .npmrc with minimum-release-age, ignore-scripts, save-exact, prefer-frozen-lockfile | Defence in depth: explicit per-repo policy that survives pnpm major-version changes; npm-fallback compatibility |
| 3 | Walk pnpm approve-builds to populate pnpm.onlyBuiltDependencies allowlist | Block lifecycle scripts by default, whitelist the few that genuinely need a build step (sharp, esbuild, @cloudflare/workerd, sqlite3) |
| 4 | Install + wire husky pre-push hook running typecheck + lint + test | Local CI gate; catches regressions before they leave the machine |
| 5 | Audit .github/workflows/ for pull_request_target triggers | Root-cause vector in the TanStack attack; force a deliberate fence or migration to pull_request |
| 6 | Quick worm-payload scan of node_modules for known signatures (bundle.js, shai-hulud, 60s-poller patterns) | Sanity check: confirm nothing infected slipped in pre-hardening |
Resolve repo path from [path] arg or cwd. If not a git repo, error out.
Detect package manager:
pnpm-lock.yaml → pnpm pathbun.lockb or bun.lock → bun path (different recipe, see § Bun)package-lock.json and no others → npm path (degraded recipe)yarn.lock → ask the user; recommend migrating to pnpmCheck mode: if --check, report the per-step diff and exit 0. Do not write.
Step 1: pnpm version
pnpm -v
If < 11: run corepack prepare pnpm@latest --activate (global) and verify. Then:
npm pkg set packageManager="pnpm@$(pnpm -v)"
If packageManager field already matches pnpm@11.*, skip.
Step 2: per-repo .npmrc
If .npmrc does not exist OR does not contain minimum-release-age=1440, write or append:
# Supply-chain hardening — applied by /ro:harden-npm
# See [security:npm-supply-chain-hardening](obsidian://open?vault=llm-wiki-security&file=wiki%2Fplaybooks%2Fnpm-supply-chain-hardening)
minimum-release-age=1440
ignore-scripts=true
save-exact=true
prefer-frozen-lockfile=true
If .npmrc already exists with other settings, preserve them. Insert the new block at the top with a comment marker, only if not already present.
Step 3: approve-builds (skipped if --no-approve-builds)
Check package.json for pnpm.onlyBuiltDependencies. If missing:
find node_modules -maxdepth 3 -name "package.json" -exec jq -r 'select(.scripts.postinstall or .scripts.preinstall) | .name' {} \; 2>/dev/null | sort -u
sharp, esbuild, @cloudflare/workerd, @swc/core, better-sqlite3, sqlite3, puppeteer, playwright, cypress, husky. These are well-known build-step packages with no history of supply-chain incidents. Write them to pnpm.onlyBuiltDependencies array in package.json.This step is interactive ONLY if unknowns are found. Otherwise silent.
Step 4: husky pre-push (skipped if --no-husky)
If package.json has husky as a dev dep AND .husky/pre-push exists: append/verify the local-CI line. If file doesn't exist or husky not installed:
# Only run pnpm add if husky truly missing
grep -q '"husky"' package.json || pnpm add -D husky
mkdir -p .husky
Write .husky/pre-push:
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
# Local CI gate — catches regressions before they leave the machine
# Installed by /ro:harden-npm
pnpm typecheck
pnpm lint
pnpm test --run
chmod +x .husky/pre-push. Make sure prepare script in package.json is husky (or husky install on older versions).
Detect package-manager-specific script names: if typecheck script is missing in package.json, try tsc --noEmit directly. Document any substitutions in a comment at the top of the hook.
Step 5: GH Actions audit
rg -n "pull_request_target" .github/workflows/ 2>/dev/null
If matches found, REPORT them (don't auto-fix). Output the file + line + surrounding context. Surface the security:github-actions-fork-pr-safety playbook link. Use AskUserQuestion with three options: keep, fence with fork-check, or migrate to pull_request.
This is the only step that can require human judgement — the right fix depends on what the workflow does.
Step 6: worm-payload scan
find node_modules -path "*/@tanstack/*" -name "bundle.js" 2>/dev/null
find node_modules -iname "*hulud*" 2>/dev/null
grep -rlE "checkGitHubToken|webhook\.site" node_modules 2>/dev/null | head -5
If anything matches, STOP and report. Recommend the user nuke node_modules, snapshot the machine, then run /ro:security-audit for a deeper scan.
Report: print a summary of which steps changed something vs were already in place. Suggest next: git diff + commit on a branch.
Commit: offer to commit with conventional message:
🔒 security: apply /ro:harden-npm supply-chain controls
- pnpm pinned to 11.x.x via packageManager
- .npmrc: minimum-release-age, ignore-scripts, save-exact
- husky pre-push wired with typecheck + lint + test
- approve-builds whitelist: <list>
- GH Actions audit: <result>
Use AskUserQuestion: commit now, commit on a new branch security/harden-npm, or stage only. Default to new branch for shared repos.
Bun's defence surface differs. Apply this subset:
| Step | Bun equivalent |
|---|---|
| Pin packageManager | npm pkg set packageManager="bun@$(bun -v)" |
| minimum-release-age | Not supported in bun. Compensate via Renovate minimumReleaseAge rule. |
| ignore-scripts | Default in bun. Populate trustedDependencies array in package.json with the same safe canonical list as pnpm's onlyBuiltDependencies. |
| blockExoticSubdeps | Not directly supported. Audit lockfile manually for non-registry resolutions. |
| approve-builds | Use trustedDependencies array instead. |
| husky pre-push | Same as pnpm path. |
| GH Actions audit | Same as pnpm path. |
Degraded mode. minimum-release-age is not supported. Apply:
npm pkg set packageManager="npm@$(npm -v)".npmrc with ignore-scripts=true, save-exact=truenpm audit --audit-level=moderate in pre-push hookStrongly suggest migrating to pnpm.
pnpm -v # 11.x
cat package.json | jq '.packageManager' # "[email protected]"
cat .npmrc | grep minimum-release-age # 1440
cat package.json | jq '.pnpm.onlyBuiltDependencies' # populated array
ls -la .husky/pre-push # exists, executable
rg "pull_request_target" .github/workflows/ # empty OR fenced
find node_modules -iname "bundle.js" -path "*@tanstack*" # empty
/ro:security-audit — pre-publish secrets/PII scan (orthogonal concern, run both)development
Close the loop on a Linear ticket when its work ships - move the status and post a deploy comment with the PR link, what shipped, and a try-it link, mentioning the collaborator. Used as the tail of /ro:linear-nightshift for every merged mirror, or manually after an ad-hoc build. Triggers on "linear update", "update the linear ticket", "mark NUT-x done", "tell eoin it shipped", "/ro:linear-update".
devops
Run a night-shift against a collaborator's Linear board. Pulls the team's Grilled tickets (/ro:linear-grill moves a ticket to Grilled once its questions are answered), VERIFIES the questions were actually answered (unanswered → bounce the ticket to the "Question for <name>" state), mirrors verified tickets to ephemeral GitHub issues with ready-for-agent, then runs the standard /ro:night-shift machinery on GitHub. Tail-calls /ro:linear-update for everything that merged + deployed. Triggers on "linear nightshift", "nightshift linear", "drain the linear board", "run the shift off linear", "/ro:linear-nightshift".
development
Grill a collaborator's Linear tickets and move every processed ticket to where it belongs. Resolves the board from the repo's .ro-linear.json, reads the collaborator's Backlog / Ready-for-agent issues, then per ticket either posts 3-5 decision-extracting questions (state moves to "Question for <name>") or confirms it build-ready (state moves to "Grilled", the gate /ro:linear-nightshift consumes); shipped-and-confirmed tickets close as Done. The async-collaborator counterpart of /ro:day-shift for people who never touch GitHub. Triggers on "grill linear", "grill eoin's tickets", "linear grill", "add questions to the linear tickets", "/ro:linear-grill".
development
--- name: about-page description: Add a standard About page to any web app, what it is, the tech stack, and an FAQ, wired into a footer link with a sticky footer. Built with Spartan + Tailwind (the canonical component layer) and falls back to semantic HTML so it ships reliably. Use whenever building, polishing, or shipping an app, every app should have one. Triggers on "add an about page", "about page", "footer about link", or as a standard step in app build/polish. category: frontend argument-h