Codifying a pnpm audit remediation SOP into an agent skill
After re-deriving the same pnpm audit remediation decision tree across five JS trees one too many times, I packaged it as an agent skill that runs the process instead of rediscovering it.
Codifying a pnpm audit remediation SOP into an agent skill
I had re-derived the same pnpm audit remediation decision tree across five JS trees one too many times. I codified it into an agent skill that runs the process instead of rediscovering it each session.
Repeated remediation work
The polyglot monorepo has one root pnpm workspace plus four standalone pnpm projects, each with its own dependency tree. Every remediation pass hit the same failure modes, because the decision tree got reconstructed from scratch each cold session:
- Editing the wrong config file. There are two pnpm config tiers; the root
pnpm-workspace.yamldoes not govern the standalone apps. A fix landed in the root that needed to land in the app, and silently did nothing. - Flooring where an exact pin was needed. A
>=patchedfloor resolved to a major the parent’s tight range couldn’t accept, breaking the parent at runtime. - Trusting a single root audit.
pnpm audit -rat the root covers the workspace packages and nothing else. The four standalone apps went un-audited until someone got burned. - Chasing the low/moderate tail manually. Below the
auditLevel: highgate, those findings are the update bot (Renovate)‘s job, not something to clear by hand every pass.
Each remediation session was re-learning the same decision tree. The fix was to encode the decision tree at the point where the work actually happens: the prompt the agent runs against.
Decision tree as a skill
The defining rule: reachability decides the tool
Before I touch anything, I classify each advisory by where the vulnerable package lives. The whole skill collapses to one rule: I bump what I own and override what I can’t reach.
| Vuln location | Fix | Why |
|---|---|---|
Direct (declared in a package.json I own) | pnpm up bump | Real, self-documenting, bot-trackable |
Transitive-only (no package.json I control declares it) | pnpm override | The only lever; the parent pins a range I can’t reach |
Low/moderate (below auditLevel: high) | Update bot | Below the gate; leave it to the scheduled PR flow |
Bump-first, override-as-last-resort. An override is a patch on someone else’s range; a real bump is preferred when the patched version is reachable.
The two-tier config trap
The skill front-loads this because editing the wrong file is the most common mistake I made across passes. A single pnpm audit -r at the root does not cover the standalone apps. Their overrides live in their own configs.
- Tier 1: the root workspace. Governs the workspace packages. They inherit the root
overrides,trustPolicyExclude,minimumReleaseAge, andallowBuilds. - Tier 2: standalone pnpm projects. Each has its own independent overrides, exclusions, and cooldown. A vuln here is fixed in that project’s config, never the root.
Per-tier audit commands:
# Tier 1: root workspace
pnpm audit -r
# Tier 2: each standalone app, audited locally
pnpm --dir web-app audit
pnpm --dir <next-app> audit
Audit all trees before declaring “clean.” Five trees, five audits.
The four override forms (with the failure mode each prevents)
This is the heart of the skill. Before choosing a form, I always run pnpm why <pkg> and inspect the parent’s declared range; an incorrect form breaks the parent at runtime.
Form A: Exact pin
Use when the parent pins a tight range and a floor would overshoot it. Example: a parent declares ~1.9.0; a >=1.9.16 floor resolves to 1.14.x and breaks the parent. Pin to the patched release inside the range.
# pnpm-workspace.yaml (root workspace, Tier 1)
overrides:
# parent declares ~1.9.0; a floor resolves to 1.14.x and breaks it
"@grpc/grpc-js": 1.9.16
Form B: Scoped range selector
Use when only a sub-range is vulnerable and a global floor would drag unaffected majors up. Example: only 8.0.0–8.0.15 is vulnerable; 7.x coexists fine and shouldn’t be yanked to 8.x.
# pnpm-workspace.yaml (root workspace, Tier 1)
overrides:
# only 8.0.0–8.0.15 is vulnerable; 7.x coexists, don't drag it up
vite@>=8.0.0 <8.0.16: 8.0.16
Form C: Global floor
Use when the package has no unaffected siblings a floor would harm.
# <app>/pnpm-workspace.yaml (standalone app, Tier 2)
overrides:
# all resolutions are vulnerable below this; nothing coexists above it
hono: ">=4.12.25"
Form D: Parent-scoped key
Use to override only the version reached through a specific parent, leaving other paths alone.
# <app>/pnpm-workspace.yaml (standalone app, Tier 2)
overrides:
# only the postcss reached through this parent; other paths untouched
"@expo/metro-config>postcss": 8.5.10
Universal conventions for every override:
- A comment naming every advisory it addresses:
# GHSA-xxxx — transitive via <parent>. - Choose the narrowest correct form: scoped selector beats global floor.
- After adding, run
pnpm installand confirm the package resolves to the patched version in the lockfile. - If the parent’s range genuinely can’t accept the patched version (a major boundary), the fix is upgrading the parent, not an override.
The layered defense (which layer catches what)
A pnpm override is one layer. Don’t conflate a tool-cooldown problem with an override problem; they have different fixes.
| Layer | Where | What it catches |
|---|---|---|
Tool cooldown (minimum_release_age = "1d") | mise.toml settings, every project | Backdoored tool releases (node, pnpm, terraform) |
pnpm audit signatures | CI on each standalone app | Packages published outside trusted pipelines |
strictStorePkgContentCheck | every pnpm-workspace.yaml | Store content anomalies |
Provenance / trustPolicy: no-downgrade | pnpm-workspace.yaml + tool lock | Provenance regressions |
| Trivy HIGH/CRITICAL gate | CI | Non-npm / OS-level vulns |
When a vuln surfaces, I confirm which layer it belongs to before reaching for an override.
Manual vs the update bot
This skill is the manual flow for active remediation. It is especially for the Tier-1 apps, because the update bot’s ignorePaths skips them, so the bot will not open vuln MRs for those. The manual skill is the only path there.
For everything else the bot manages:
- Security PRs bypass the weekly schedule but still respect the 1-day cooldown.
- Automerge is off everywhere: human review required.
- Don’t manually chase the low/moderate tail. That’s below the
auditLevel: highgate and is the bot’s job.
One cooldown interaction bites the manual flow: minimumReleaseAge: 1440 (24h) may block a freshly released patch from installing. When the patched version is less than 24 hours old, the choices are to wait, or to add a temporary minimumReleaseAgeExclude entry with a removal note, and remove it once the package ages out. The exclusion list is the part that rots; the skill calls out a cull pass every time.
Lockfile discipline
Every project sets preferFrozenLockfile: true, which trips up the manual flow in two ways:
pnpm install # after a package.json edit — regenerates the lockfile
pnpm install --frozen-lockfile # proves CI parity (re-freezes)
pnpm audit -r # + each standalone app, re-run
pnpm install right after a package.json edit does normal resolution: the lockfile no longer satisfies package.json, so the frozen flag is ignored for that one run; subsequent installs re-freeze. This does not interfere with the update bot, which edits package.json before install. The mistake is skipping the --frozen-lockfile re-verify: CI uses frozen lockfiles, and a non-reproducible install will fail CI after the fact.
Verification
- Drove all five JS trees to 0 high/critical audit findings.
- Standardized cooldown across the repo:
1440minutes in pnpm config equals"1 day"in the bot config. Same policy, different units: not drift to “fix.” - Every override carries a
# GHSA-…comment and the transitive parent. Reviewable now, self-documenting on the next audit. - The skill is the reusable artifact: the next remediation runs the process instead of re-deriving it.
Limits
I waited too long to write the skill. The first two or three remediation passes were where the decision tree actually hardened; by pass five it was already stable, and I was repeating the same warnings (“audit all five trees,” “check the parent’s range before choosing the form”) verbally every session. The signal that it was ready: I was giving the same guidance, verbatim, unprompted. I should have written it after the second pass.
References
- pnpm
overrides. The four authoring forms (exact pin, scoped selector, global floor, parent-scoped key). - pnpm catalog. Unify shared versions inside a workspace without editing every
package.json. - pnpm
audit.auditLevel,--recursive, and per-tree auditing. - pnpm supply-chain settings.
minimumReleaseAge,trustPolicy, and the exclusion lifecycle. - mise. Tool-version cooldown source of truth.
- Trivy. OS-level / non-npm vulnerability gate.
- Keeping Supply Chain Exclusions Honest. The earlier post on stopping exclusion lists from rotting: the design backdrop for the lifecycle rules in this skill.
- Beyond Version Checks: Provenance and Behavioral Security. Why version-level controls are necessary but not sufficient: the wider defense this skill slots into.
- agents.md. The agent-skills format used to package this SOP so the process is invoked at the point of work.
This post was written with AI assistance.