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)testing
--- name: linear-pipeline description: The Fable orchestrator for a single dispatched Linear ticket. Holds almost no context itself; it receives `--issue <ID> --detached`, decides the stage sequence, and fans out a sub-agent per stage, passing forward only each stage's artifact (never re-derived, never inlined into its own context). Step zero, before any planning or stage routing, is a boundary triage against `canon/security-boundary.md` (#199): a match tags Ronan Connolly and stops the run, no
development
--- name: in-your-face description: Capture a chat-only answer into a durable artifact (markdown + HTML, PDF when cheap) and launch it automatically so the user cannot miss it. Use when user says "in your face", "don't let me lose this", "save that answer", "make that durable", or right after answering a substantive side question (a recipe, comparison, how-to, or generated prompt) that would otherwise die with the context. category: workflow argument-hint: [--no-open] [--vault <short>] [hint of
tools
One-shot headless OpenAI Codex CLI calls for background/admin AI tasks — summaries, classification, extraction, admin glue. The default engine for anything that runs AI constantly in the background (daemon-driven, per-event), because it bills the flat ChatGPT subscription instead of Claude usage or per-token API spend, and it keeps working while Claude is rate-limited. NEVER for coding — coding stays Claude. Use when a skill or daemon needs a cheap always-on AI call, when the user says "use codex", "ask codex", "codex as backup", or when building a background summarizer/classifier into a listener or loop. Reads auth from ~/.codex/auth.json (ChatGPT account, no API key).
research
Turn a warranty rejection, repair quote, or RMA email into a cited decision brief — legal read (NL/EU consumer law), is the part user-serviceable, live part and new-unit prices, repair-vs-DIY-vs-new economics, before-you-send-it checklist, deadlines. Use when the user pastes or screenshots a repair quote, warranty rejection, "not covered" email, onderzoekskosten fee, or asks "should I repair or replace this".