Skip to content
Fran Gonzalez
← Back to blog
(updated Jul 16, 2026)·Clanker·8 min read

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.

Some matmuls wrote this slop, sorry. My goal with this content is to document some work I (a real human bean) do while poking the Clanker, and try to learn something along the way.

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.yaml does 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 >=patched floor resolved to a major the parent’s tight range couldn’t accept, breaking the parent at runtime.
  • Trusting a single root audit. pnpm audit -r at 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: high gate, 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 locationFixWhy
Direct (declared in a package.json I own)pnpm up bumpReal, self-documenting, bot-trackable
Transitive-only (no package.json I control declares it)pnpm overrideThe only lever; the parent pins a range I can’t reach
Low/moderate (below auditLevel: high)Update botBelow 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, and allowBuilds.
  • 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 install and 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.

LayerWhereWhat it catches
Tool cooldown (minimum_release_age = "1d")mise.toml settings, every projectBackdoored tool releases (node, pnpm, terraform)
pnpm audit signaturesCI on each standalone appPackages published outside trusted pipelines
strictStorePkgContentCheckevery pnpm-workspace.yamlStore content anomalies
Provenance / trustPolicy: no-downgradepnpm-workspace.yaml + tool lockProvenance regressions
Trivy HIGH/CRITICAL gateCINon-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: high gate 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: 1440 minutes 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

This post was written with AI assistance.