npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

npm-script-lens

v1.18.0

Published

See what an install script actually does before you approve it: behavioral analysis, binding.gyp inspection and resolved provenance identity for npm 12 allowScripts, pnpm allowBuilds, yarn and bun

Readme

npm-script-lens

Know what an install script actually does before you approve it.

The install-script-approval tool for npm · pnpm · yarn · bun, from your CLI, CI, editor, and AI agent.

Since npm v12 (July 8, 2026), dependency lifecycle scripts (preinstall, install, postinstall) and implicit node-gyp builds no longer run unless explicitly allowed via the allowScripts field in package.json. git and remote-URL dependencies no longer resolve at all unless opted in via allow-git/allow-remote. And npm isn't alone: pnpm (allowBuilds), yarn Berry (dependenciesMeta.built), and bun (trustedDependencies) all made install scripts opt-in too. That leaves every team, on every package manager, staring at a list of package names asking: which of these are safe to approve?

npm-script-lens answers that with evidence, not vibes: the review-report mode the community asked for in npm/rfcs#897. For every package in your lockfile (package-lock.json, npm-shrinkwrap.json, yarn.lock (classic and berry), pnpm-lock.yaml, or bun.lock) it:

  1. fetches the version metadata from the public npm registry,
  2. stream-downloads the tarball and indexes its source files (tar-stream, nothing written to disk), skipped entirely for the majority of packages with no install-time scripts, which is why real audits take seconds,
  3. statically analyzes each preinstall/install/postinstall script with acorn, including the JS the script actually runs: node <file> targets, node -e eval bodies, relative require()/import chains, path.join(__dirname, …) indirections, and npm run <target> recursion into the package's own scripts (3 levels deep, cycle-safe). Packages that ship a root binding.gyp with no install script get their implicit node-gyp rebuild surfaced too, since npm v12 blocks those builds as well. (prepare is deliberately excluded: npm never runs it for registry-installed deps, and flagging leftover "prepare": "husky install" lines would be noise.)
  4. reads inside binding.gyp (and the .gypi/.gyp files it includes) for native packages, see the gyp lens, because gyp runs the commands in that file at configure time,
  5. scores the behavior and emits a Markdown report plus a ready-to-paste, version-pinned allowScripts block,
  6. adds context to every risky package: how it entered your tree (via prisma → @prisma/engines), whether OSV lists it as malicious (⛔ hard flag, always denied), and publisher trust signals (publish age, weekly downloads, maintainer count, and the resolved provenance identity: which repository, workflow, ref and commit the attestation claims built it), so "🔴 HIGH, 74M dl/wk, 10 years old" reads differently from "🔴 HIGH, published 4 days ago, 12 dl/wk".

| Risk | Meaning | |---|---| | 🔴 HIGH | spawns processes (child_process, execa, node-gyp, unresolved binaries) or runs constructed code (eval, new Function, vm, string-built require(), base64/char-code payload decoding) or reads a credential store and reaches the network (~/.npmrc, NPM_TOKEN, .aws/credentials, …) | | 🟠 MEDIUM | network access (http(s).get/request, fetch, axios/got/node-fetch/…) without exec | | 🟡 LOW | filesystem writes, process.env reads, or a credential read with no network access | | 🟢 SAFE | none of the above |

Results are cached on disk keyed name@version + tool version (published tarballs are immutable), so repeat audits are near-instant and fully offline. --no-cache opts out; NPM_SCRIPT_LENS_CACHE_DIR relocates the cache.

CLI

npx npm-script-lens audit --path ./my-project --fail-on-high
# --path PATH   project dir or lockfile: package-lock.json, npm-shrinkwrap.json,
#               yarn.lock, pnpm-lock.yaml, bun.lock (default: .). A directory
#               with no lockfile searches upward like npm does; a directory of
#               checkouts audits every project underneath
# --json        machine-readable output ({results, allowScripts}, or {projects}
#               when more than one project was found)
# --out FILE    write report to a file
# --sarif FILE  also write SARIF 2.1.0 for GitHub code scanning
# --html FILE   also write a self-contained, shareable HTML report
# --diff BASE   audit only packages added/upgraded vs a base lockfile
# --since REF   like --diff, but extract the base lockfile from a git ref
# --offline     analyze node_modules on disk instead of the registry
# --no-trust    skip OSV/downloads/provenance enrichment
# --no-cache    disable the on-disk result cache
# --fail-on-high  exit 1 if any package scores HIGH or is known malicious
# --cooldown [h]  exit 1 if any locked version is younger than N hours (default 72)
# --cooldown-allow PKG...  exempt from --cooldown, by name or name@version
# --fail-on-downgrade  exit 1 if any package resolves below the highest trust
#               tier it previously reached (npm/cli#9242); policy
#               trustPolicy: "no-downgrade" reports without failing
# --fail-on-runtime-bootstrap  exit 1 if any package installs another JS runtime
#               (bun, deno) at install time; policy runtimeBootstrapPolicy:
#               "fail" arms the same gate
# --runtime     also read the code each package runs when required (main,
#               exports, bin) for C2/exfil endpoints and payload loaders, see
#               Payloads without install scripts
# --fail-on-runtime-payload  exit 1 on a HIGH RUNTIME_PAYLOAD (implies --runtime)

Reviewing a PR? Audit only what changed, and see what upgrades gained:

npx npm-script-lens audit --since origin/main --fail-on-high   # base lockfile pulled from the ref for you
# …or point --diff at a base lockfile you extracted yourself:
git show origin/main:package-lock.json > /tmp/base-lock.json
npx npm-script-lens audit --diff /tmp/base-lock.json --fail-on-high

In diff mode, a package that was already in the tree but changed version is compared against the base version's analysis: **⚠️ gained vs 1.2.0:** net: fetch() is the fingerprint of a hijacked release (event-stream, the 2025 Shai-Hulud wave); no new capabilities vs 1.2.0 is a boring upgrade.

The gyp lens: what is actually inside binding.gyp?

npm-script-lens is the only install-script allowlist/approval tool that reads inside binding.gyp and .gypi, and the only one that diffs them between versions.

Every such tool (including this one, before v1.3.0) treated binding.gyp as a flag: present ⇒ "implicit node-gyp rebuild". But gyp evaluates that file before a line of C is compiled, and executes the commands in it, subprocess.run(contents, stdout=PIPE, shell=use_shell, …) in gyp-next. So the build file is a place to put install-time code where approval tooling was not looking. That is what the June 2026 campaign used: ReversingLabs, 2026-06-04 (286 malicious versions across 56 packages), whose payload was a single line:

{"targets":[{"target_name":"Setup","type":"none","sources":["<!(node index.js > /dev/null 2>&1 && echo stub.c)"]}]}

Aikido's 2026-06-09 teardown enumerates the channels; npm-script-lens covers all of them:

| Channel | What it does | |---|---| | <!( <!@( | command expansion: gyp runs it in a shell and substitutes the output | | >!( >!@( ^!( ^!@( | the same thing in gyp's late and latelate phases, one character apart from <!(, and invisible to a naive scan | | <!pymod_do_main( (+ >/^) | imports a Python module and calls its DoMain() | | <\|( >\|( ^\|( | listfile expansion | | actions[].action · rules[].action · postbuilds[].action | explicit build steps that run commands | | make_global_settings | replaces CC/CXX/LINK, a compiler hijack | | conditions | flagged when the condition string reaches for the Python-eval sandbox escape (__class__, __subclasses__, __import__, __builtins__) |

Plain <(var) / <@(var) interpolation is never flagged, real files mix both, and bufferutil's <!(cc -v …) sitting next to its <(clang_version) is a committed regression test.

review prints what will run above the file itself. Real output for [email protected], note the findings come from deps/sqlite3.gyp, a file the parent binding.gyp only references:

── [email protected]  [🔴 HIGH]
   deps/sqlite3.gyp:28  actions[].action build action → node copy.js <(SHARED_INTERMEDIATE_DIR)/sqlite3
   deps/sqlite3.gyp:41  actions[].action build action → node copy.js <(SHARED_INTERMEDIATE_DIR)/sqlite3 <(sqlite3)
   ┌─ binding.gyp (39 lines)
   │   1  # ===
   │   2  # This is the main GYP file, which builds better-sqlite3 with SQLite itself.
   …

In audit, these become gyp: signals that score HIGH (a shell command at install time is a shell command), appear in --sarif under the rule gyp-exec-channel, and can be banned outright via a policy's denyCapabilities: ["gyp"].

Upgrading from ≤ 1.2.0? A manifest --check baseline containing native packages may now show a new gyp capability, the tool sees something it previously could not. Re-baseline once with manifest --write and commit it.

RUNTIME_BOOTSTRAP: installing a different runtime to step outside the model

Approval tooling models a Node payload. It reads the JavaScript that preinstall runs, follows its require() chain, and scores what it does. That model has an edge, and on 2026-08-04 the ChainDrop worm walked out of it across more than 400 packages.

Its preinstall was node setup.mjs, a command this tool already followed. The escape was inside that JavaScript. The dropper fetched a real, signed Bun release from the project's own GitHub releases, unpacked it, and ran a bundled second stage under that fresh interpreter. Microsoft's own detections name the technique: "Suspicious installation of Bun runtime." Two things about that stage defeated a Node-shaped scan: it ran under a different interpreter, and it was never require()d, it was passed as a file path to a spawn call, so the require-walk never reached it.

So the runtime table is no longer one entry long. A file handed to node, nodejs, bun, deno run, tsx, ts-node, or the npx / bunx / bun x runner forms is opened from the tarball and analyzed with the same acorn pass, and its capabilities merge into the package score. Inside that pass, a download whose URL points at a JavaScript-runtime distribution is flagged, and a child_process / spawn / exec call whose string argument resolves to a file in the tarball is queued into the same walk, so the second stage is analyzed like any other entry point.

A package that does this gets a RUNTIME_BOOTSTRAP finding, scored HIGH, that names the technique instead of folding it into a generic "spawns processes":

🔴 HIGH  RUNTIME_BOOTSTRAP  downloads bun from oven-sh/bun releases, then runs it on the bundled payload

The capabilities of that bundled payload (its network calls, its environment reads) are merged into the same finding and attributed to the bootstrapped runtime, so a stage that reads a token and phones home reads as exactly that, not as a mystery second file. The detector recognises the two runtimes' own install scripts, their release archive names, the oven-sh/bun release URLs, a global install of bun or deno through a package manager, and a shell pipe that installs one of them. Both shapes ChainDrop used land here: the node setup.mjs dropper, and the shell one-liner that installs bun and then runs a bundled payload with it.

Installing a runtime is the signal; using one is not. A package that runs bun run build on a script it already ships is not flagged, that is an ordinary bun-native build. An alternate-runtime entry point that does not resolve to a file in the tarball degrades to the generic HIGH rather than crashing.

In audit --diff / --since, a version that newly acquires a runtime bootstrap gets an explicit line, because ChainDrop republished each hijacked package as an ordinary patch bump:

⚠️ gained vs 1.0.0: runtime bootstrap (bun)

--fail-on-runtime-bootstrap exits 1 on the pattern, independent of --fail-on-high, and a checked-in runtimeBootstrapPolicy: "fail" in script-lens.policy.json arms the same gate for a whole team. The finding rides --json (results[].runtimeBootstrap) and SARIF (rule runtime-bootstrap, level error) alongside every other finding.

Payloads without install scripts

Everything above reads what runs at install time. In September 2026 the btree campaign (Checkmarx) skipped that entirely. indexed-btree and ten companion packages had no lifecycle scripts, so there was nothing to approve and nothing for an allowScripts review to show. The loader was in BTree.prototype.set: on the hundredth insert it spawned a detached node on a bundled, obfuscated extended/sharedLoad.min.js, which looked up its C2 on an Ethereum testnet and posted what it collected to chat bots. It ran the first time your own code used the library.

--runtime reads that code. For each package it takes main, every exports target (all conditions, subpaths and nesting) and every bin, falling back to index.js like Node does. It then follows relative require/import and any file the code spawns node on, with the same acorn pass the install-script analysis uses. A bin is read whatever its name if it opens with #!. Type declarations, .json, .node, source maps, wildcard subpaths and folder mappings ("./": "./") are skipped.

npx npm-script-lens diff [email protected] [email protected] --runtime
npx npm-script-lens audit --runtime                       # every locked package
npx npm-script-lens audit --fail-on-runtime-payload       # the same, and exit 1 on HIGH

On the repo's inert re-creation of the btree release (fixtures/runtime):

[email protected] → [email protected]
runtime code (main/exports/bin): index.js
GAINED: c2 (extended/sharedLoad.min.js) contract 0x5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e
GAINED: c2 (extended/sharedLoad.min.js) eth-sepolia.g.alchemy.com
GAINED: c2 (extended/sharedLoad.min.js) eth_call
GAINED: exec-local (index.js) process.execPath extended/sharedLoad.min.js (detached)
GAINED: exec (index.js) child_process.spawn()
GAINED: exec (index.js) require('child_process')
GAINED: exfil (extended/sharedLoad.min.js) api.telegram.org/bot
GAINED: net (extended/sharedLoad.min.js) fetch()

A plain diff of the same two versions exits 0, since no script changed. With --runtime, gaining exec, exec-local, c2, exfil or obf exits 1. Gaining only net, fs or env is printed but does not fail, because ordinary releases gain those all the time. A signal that moved to another file is not a gain, and neither is a bundler renaming worker.3f9a1c2b.js to worker.8e41d0aa.js. The upgrades checked before release (chalk 5.4.0 → 5.4.1, commander 14.0.2 → 14.0.3, semver 7.6.2 → 7.6.3, debug 4.3.6 → 4.3.7, ms 2.1.2 → 2.1.3, axios 1.7.7 → 1.7.8, date-fns 3.6.0 → 4.1.0) all print no runtime capability changes.

What it looks for:

  • exec-local: node, process.execPath, bun or tsx started on a file in the same tarball, with (detached) when it outlives the parent. That is the loader shape. A worker pool does it too, so on its own it is MEDIUM.
  • c2: an Ethereum RPC host (testnets, infura, alchemy, publicnode, ankr), eth_call, and a 40-hex contract address in a file that also names an RPC host.
  • exfil: Telegram bot, Slack and Discord webhook endpoints, including ones split across '…' + '…' concatenation. A docs link to the same service does not count.
  • obf: string-array rotation: the prelude javascript-obfuscator emits when it encodes a file's strings into a rotated array. No file in a 746-package web3 tree (22,570 JS files) has it.

audit --runtime reports only those four as RUNTIME_PAYLOAD, never plain exec or network, which most libraries have. A lone RPC host is normal in a web3 client and a lone local spawn in a worker pool, so one kind alone is MEDIUM. An exfil endpoint, or two kinds together, is HIGH, and only HIGH fails --fail-on-runtime-payload. On that 746-package web3 tree it took 20 seconds and reported 0 HIGH and 12 MEDIUM: wallet SDKs naming their infura endpoints, RPC clients that build eth_call requests, ethers' block explorer hosts, and pm2 starting its own daemon detached. Runtime mode downloads every tarball, not just the ones with install scripts, so the first run is slower than a normal audit. Results are cached like everything else.

With --diff or --since, a finding on an upgraded package marks each hit the old version did not have as new since that version, and says so when every hit was already there. That is the line to read first: a wallet SDK that always named infura is not news, the same SDK gaining a Telegram endpoint is.

A package with no finding is not always a clean read. When an entry point could not be read (over 2 MB, declared but not in the package) or the walk ran out of budget, the report lists it under Runtime code only partly read so the silence is not mistaken for a pass.

What it cannot see. Strings built at runtime stay opaque: a URL decoded from an obfuscated string array, fetched from somewhere, or assembled from pieces that are not all literals. The real sharedLoad.min.js is that kind of file. Going by Checkmarx's description of it, expect exec-local on the loader and the string-array rotation on the payload, not the endpoints. That is still two kinds, so HIGH, and still a failing diff. Real Telegram client libraries name the bare API host and add the /bot path later, so they do not fire, and neither would a payload written the same way. The walk reads up to 200 files per package, with no limit on how deep the require chain goes. main and everything it pulls in come first, then each exports target and bin in turn. Past 200 the output says partial (date-fns, with 1,480 exports targets, reaches it), and anything beyond it is unread. A declared entry point that is not in the tarball is reported with the reason, not fatal.

diff: what did an upgrade change in the install scripts?

Before you bump a pin, see exactly which install-time behavior a new version adds or changes, the surface npm v12 will ask you to re-approve. diff compares the preinstall/install/postinstall scripts (and the implicit node-gyp rebuild that ships with a root binding.gyp) between two versions, straight from the registry:

npx npm-script-lens diff [email protected] [email protected]
# --json   emit { unchanged, added, removed, modified, gyp } instead of colored text
# --runtime  also compare the code each version runs when required, see
#            Payloads without install scripts
[email protected] → [email protected]
REMOVED: implicit node-gyp rebuild (binding.gyp)
MODIFIED: install
    - (node install/libvips && node install/dll-copy && prebuild-install) || …
    + node install/check
  • UNCHANGED (green): key present in both, byte-identical
  • ADDED (red): a new script, or a gained binding.gyp → ADDED: implicit node-gyp rebuild (binding.gyp)
  • REMOVED (yellow): a script that went away
  • MODIFIED (red): same key, changed content, with a line-level diff
  • PROVENANCE IDENTITY CHANGED (red): the attested build identity moved, see below

diff also resolves each version's provenance identity and compares it. A version built from acme/widget .github/workflows/release.yml@refs/tags/v1.2.0 upgrading to one built from .github/workflows/hotfix.yml@refs/heads/main prints:

PROVENANCE IDENTITY CHANGED  workflow .github/workflows/release.yml → .github/workflows/hotfix.yml, ref refs/tags/v1.2.0 → refs/heads/main

and exits 1, the same gate as an added or modified install script: the workflow that produced the artifact is not the one that produced the version you already trust. Provenance appearing or disappearing across the upgrade gates too. A new commit alone never does (every release has one), and an identity that cannot be resolved on either side is never compared, so registry hiccups cannot fail your CI. When the identity is present and unchanged, a green UNCHANGED: provenance identity … line says so.

binding.gyp is compared by content, not by existence (fixed in 1.3.0). A version that keeps its build file but rewrites it changes what runs at install time, and used to slip through as UNCHANGED / exit 0, the shape the June 2026 wave-2 releases had. Now:

$ npx npm-script-lens diff [email protected] [email protected]
[email protected] → [email protected]
UNCHANGED: install
MODIFIED: binding.gyp (implicit node-gyp rebuild, contents changed)
      {
    +   'variables': {
    +     'openssl_fips': ''
    +   },
        'targets': [
…
$ echo $?
1

--json carries { gyp: { changed, gainedChannels } }; gainedChannels lists gyp execution channels present in the new version and absent from the old (here it is empty, a benign build-file edit, no new way to run a command). It also carries { provenance: { changed, changes, old, new } } with the resolved identity of both sides.

Exit 0 when everything is unchanged; exit 1 the moment any script is added or modified or the provenance identity changed, so a Renovate/Dependabot CI step can fail the moment an upgrade grows its install-time surface. (A pure removal stays exit 0.)

git and remote dependencies: the other two npm v12 flips

npm v12 doesn't just gate install scripts, it also stops resolving git dependencies (github:user/repo, git+ssh://…) and remote tarball URLs (https://…/pkg.tgz) unless you opt in via allow-git / allow-remote in .npmrc. Both are the strict enum all | none | root (default none); root allows only deps declared in your root package.json, so a single transitive git dep forces all. There's no migration tooling upstream, the official discussion's best offer is grep -r 'git+' package.json. sources does the whole job:

npx npm-script-lens sources                # report + the minimal correct .npmrc
npx npm-script-lens sources --check        # CI: exit 1 on insufficient, over-permissive, or invalid config
npx npm-script-lens sources --write        # merge the minimal values into .npmrc (comment-preserving)
npx npm-script-lens sources --json         # { git, remote, npmrc }

Real output for a project with a root-declared git dep and a transitive one:

git dependencies (2)
  ROOT        left-pad @ github:left-pad/left-pad
  TRANSITIVE  some-pkg @ git+ssh://[email protected]/a/b.git   via my-lib -> some-pkg
remote dependencies (0)

minimal correct .npmrc:
  allow-git=all

allow-git=all is required because 1 git dependency is transitive; allow-git=root would otherwise suffice.
Re-point or drop `some-pkg` (via my-lib) to tighten this to allow-git=root.

It reads all four lockfile dialects (package-lock v1/v2/v3, yarn classic + berry, pnpm, bun.lock) with zero network calls, and --check fails in three distinct ways so CI tells you what to do:

  • insufficient: npm v12 will refuse the install (missing .npmrc, or root committed while a transitive git dep exists);
  • over-permissive: all committed where root (or nothing) suffices: the least-privilege ratchet;
  • invalid: allow-git=true or a bare --allow-git, which several published migration guides recommend, is not in the enum: npm treats it as unset and your install still breaks. The check names the valid three.

allow --ci-check folds the insufficient/invalid cases into its fast CI gate too, and doctor reports whether your npm has the keys at all (they appeared in 11.10.0 / 11.15.0) and warns that allow-git=root is unreliable on npm 11 (npm/cli#9189, closed via PR #9206, root-level git deps were wrongly rejected): prefer all there. The .npmrc emitter is npm-only; for yarn/pnpm/bun lockfiles the dependency report still works, the write is skipped with a note.

cooldown: don't be the first to install a version

Every other check here asks what does this package do?. Cooldown asks only how old is this version?

On 2026-08-04 attackers compromised the keyv maintainer's GitHub account (~127M weekly downloads) and pushed the Mini Shai-Hulud worm into keyv, cacheable, flat-cache and file-entry-cache. It spread to nine unrelated organisations in about half an hour and 400+ packages that day, and, like every worm before it, was identified and unpublished within hours. The install that hurts you is the one that lands inside that window.

So don't go first.

# fail the build if any locked version is younger than 72h (the default)
npm-script-lens audit --cooldown

# stricter, or looser
npm-script-lens audit --cooldown 24

# ship a genuine same-day fix anyway
npm-script-lens audit --cooldown --cooldown-allow [email protected]
✗ cooldown 72h: 1 package version(s) published too recently:
  [email protected]  6.0h old  (clears 2026-08-07 14:00Z)

These may be perfectly fine. Cooldown does not inspect them. It declines to be
among the first to install a version, because npm worms are typically caught
within hours. Wait, pin to an older version, or exempt with --cooldown-allow.

Notes:

  • It is opt-in. Without the flag, nothing changes.
  • Age comes from the absolute publish timestamp at evaluation time, never from a cached day-resolution age, a stale cache errs toward older, which would fail open on precisely the young version this is meant to catch.
  • Packages with no publish date (--offline, private registries) are listed as unchecked rather than blocked.
  • Because a poisoned version doesn't need a lifecycle hook to hurt you, --cooldown fetches publish dates for every locked package, not just the ones with install scripts. That is more registry traffic than a plain audit.

cooldown config: your package manager has one too, in a different unit

--cooldown above is a CI gate. It reads the lockfile and fails the build. It says nothing about the cooldown a contributor's own pnpm install applies, and all four managers now have one:

| manager | file | key | unit | its own default | |---|---|---|---|---| | npm | .npmrc | min-release-age | days | null, no gate | | pnpm | pnpm-workspace.yaml | minimumReleaseAge | minutes | 1440 since pnpm 11, 0 before | | yarn | .yarnrc.yml | npmMinimalAgeGate | minutes, or a duration string | 1d since Yarn 4.15.0 | | bun | bunfig.toml [install] | minimumReleaseAge | seconds | null, no gate |

So 3 is three days, three minutes or three seconds depending on the file, and getting it wrong does not fail. The install succeeds and the gate is not there. yarnpkg/berry#6991 is that happening to someone: they set npmMinimalAgeGate: "7d" from the docs and a five-day-old release installed anyway, because their Yarn predated duration strings and read it through parseInt, giving seven minutes.

npm-script-lens cooldown            # report, always exits 0
npm-script-lens cooldown --check    # exit 1 on MISSING, UNIT-SUSPECT or DRIFT
npm-script-lens cooldown --write --cooldown 72

A pnpm project with minimumReleaseAge: 3 and a CI job running npm-script-lens audit --cooldown 72:

$ npm-script-lens cooldown --check
cooldown config — pnpm (pnpm-workspace.yaml)
  UNIT-SUSPECT  minimumReleaseAge: 3  → 3 minutes (pnpm counts MINUTES), not 3 days
                pnpm's own default is 1440 (1 day); 3 gates essentially nothing
  DRIFT         configured 0.05h < enforced 72h (--cooldown in .github/workflows/ci.yml)
                A version this project's own CI would block still installs on a contributor's machine.
  fix:  npm-script-lens cooldown --write --cooldown 72
        writes  minimumReleaseAge: 4320

--write then puts 4320 in, leaving every other key, comment, blank line and the file's EOL style exactly as they were.

The four statuses:

  • OK: configured, in the right unit, at or above what CI enforces. The converted hours is printed either way, so you can check the arithmetic.
  • MISSING: nothing configured while --cooldown is enforced in CI. Only CI is protected; every local install goes straight through. If a pnpm project has minimumReleaseAge sitting in .npmrc, the finding says so and points at that line: pnpm reads only auth and registry settings from .npmrc, so the value is dead where it is.
  • UNIT-SUSPECT: the value gates under an hour or over a month, and the same number reads as a sensible cooldown in one of the other managers' units. Both halves are required, so a deliberately odd threshold with no plausible alternative reading is reported OK, not flagged.
  • DRIFT: configured below the enforced threshold, or exempt lists that disagree with --cooldown-allow. The comparison uses what the manager actually gates, which is not always what the file says: an old Yarn truncating 7d to seven minutes drifts on seven minutes.

Two more lines appear in the report but never fail --check, because neither is something the project got wrong:

  • PARTIAL: a config file this tool cannot round-trip. Nothing is rewritten.
  • UNSUPPORTED: a --cooldown-allow entry this manager cannot express, so it was left out rather than written into a key that would ignore it.

Where the enforced number comes from: --cooldown <hours> if you pass it, otherwise the strictest --cooldown this repo's own CI configs run (.github/workflows/*.yml, .gitlab-ci.yml, .circleci/config.yml, searched upward so a monorepo package finds the root's). If nothing enforces a cooldown anywhere, there is no MISSING and no DRIFT to report.

Exemptions are translated into the manager's own exclude key, and only in the forms that manager accepts:

| manager | exclude key | accepts | |---|---|---| | npm | min-release-age-exclude | names or minimatch globs | | pnpm | minimumReleaseAgeExclude | names, @myorg/* patterns, versions joined with \|\| | | yarn | npmPreapprovedPackages | package descriptors or name globs | | bun | minimumReleaseAgeExcludes | package names only |

npm-script-lens cooldown --write --cooldown-allow urgent-fix [email protected] against a bun project writes urgent-fix and skips [email protected] with a note: bun matches on names, so writing the descriptor would be a gate you believe in and bun ignores. Nothing is ever exempted that you did not pass.

Yarn specifics, because Yarn's gate has moved four times:

  • Duration strings (3d, 1w) arrived in 4.11.0 (#6942). Below that the setting is SettingsType.NUMBER and a string is silently truncated by parseInt. This is reported against the version your project pins (packageManager, then yarnPath), never as a blanket warning: on current Yarn 7d is exact.
  • --write emits Yarn's own 3d idiom, and bare minutes instead on a project pinned below 4.11.0.
  • A per-scope gate under npmScopes (#7156, Yarn 4.17.0) is read and reported. It is never rewritten: exempting an internal registry is what that feature is for.

Other surfaces:

npm-script-lens audit --cooldown 72 --cooldown-config   # both halves in one run
npm-script-lens cooldown --json
npm-script-lens cooldown --check --sarif cooldown.sarif # rule cooldown-config
npm-script-lens doctor                                  # one line, with the converted hours

In the Action, opt in the same way as sources-check:

- uses: Booyaka101/npm-script-lens@v1
  with:
    cooldown-check: 'true'

Limits worth knowing: a bunfig.toml this tool cannot round-trip (a multi-line array, for instance) is reported PARTIAL and --write refuses it rather than reformatting your file. The enforced threshold is found by reading --cooldown out of CI files; a threshold assembled from a shell variable at runtime is not visible to it, so pass --cooldown <hours> in that case. And this reads committed config only, never your global ~/.npmrc: the point is what everyone who clones the repo gets.

Provenance: an identity, not a checkbox

The malicious [email protected] of 2026-08-04 carried a valid npm attestation naming GitHub Actions as its trusted publisher. Snyk's teardown draws the boundary in one sentence: "Provenance can faithfully attest a build whose source or workflow context has already been compromised." A green provenance ✓ on its own is therefore close to worthless as a trust signal, and this tool no longer stops there. When a version carries an attestation, the audit resolves what it actually claims, from the registry's attestation endpoint (the SLSA v1 predicate): the source repository, workflow path, ref, commit and builder. The trust line in every report reads:

2.1y old · 127M dl/wk · 1 maintainer · provenance ✓ github.com/jaredwray/keyv .github/workflows/release.yml@refs/heads/main 4a91c0e

and provenance ✓ (identity unavailable) when the registry's answer is not a shape this build can resolve. The identity rides the same 24h trust cache, and --offline / --no-trust make zero attestation requests.

What we read, and what we do not verify. This tool reads the claims the registry serves over TLS and does not verify Sigstore signatures or transparency-log inclusion. That is the same trust boundary as the tarball itself: if you trust the registry to hand you the package bytes, these are the claims it hands you alongside them. Nothing here should be described as cryptographic verification, because it is not.

Honesty about what this catches. ChainDrop's attacker published through each project's own repository and own release workflow, so the attested identity matched perfectly and this feature would not have caught it. Neither would it have caught the TanStack compromise three months earlier (2026-05-11, 84 malicious versions across 42 @tanstack/* packages, the first worm to ship validly-attested malicious packages). There, a pull_request_target workflow that publishes nothing poisoned a shared pnpm cache; release.yml restored it on main; the payload read the OIDC token out of the runner's memory and posted straight to the registry. The postmortem is explicit about what an identity check would have seen: "The token's attested identity still matched TanStack/router release.yml@refs/heads/main." Worth noting that this tool's own DANGEROUS gate would have missed it too, since it flags a publish path reachable from pull_request_target and that publish path was not reachable from one. The link was a shared cache scope, not the trigger graph.

The defence in this tool for both events is --cooldown, and the window is narrow enough for it to work: TanStack's malicious versions were publicly identified within 20 to 26 minutes. What identity resolution does catch: an attested repository that disagrees with the package's declared repository, and a build identity that moves between the version you trust and the version you are installing, the diff gate above and the expectProvenance policy pin. Honesty about prior art, because there is real prior art here. npmjs.com's package page already shows Build Environment, Build Summary, Source Commit and Build File for a provenance-carrying version. npm audit signatures checks registry signatures and attestations. And cosign already pins an expected identity, cryptographically, which this tool does not do:

cosign verify-blob-attestation --bundle npm-provenance.sigstore.json --new-bundle-format \
  --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
  --certificate-identity-regexp="^https://github.com/npm/node-semver/.github/workflows/release-integration.yml.?" \
  semver-7.6.3.tgz

If you need cryptographic assurance for one package, use that, not this. What those tools do not do is work at tree scale: cosign verifies one artifact against one bundle you already fetched, and the npmjs.com page covers one package you are already looking at. This tool resolves the identity for every dependency in one CI run, diffs it across an upgrade, and expresses the expectation as checked-in policy rather than a regex in a shell command. That is the gap it fills, and it is narrower than "we surface provenance identity."

Two more surfaces carry it: audit --diff/--since reports provenance identity changed vs <base> per upgraded package next to the capabilities-gained note (informational there, it never changes the audit exit code), and SARIF gains provenance-identity-changed (warning) plus provenance-repo-drift (note). The repo-drift note fires when the attestation names a different owner/repo than the packument declares, with both values spelled out. It is deliberately informational and never gates anything: npm requires your package.json repository to match where you publish from with provenance and re-checks the linked source when provenance is viewed, so a live mismatch is almost always a repo rename or transfer since publish, not an attack. Monorepo subpaths never count as drift. doctor reports whether the attestation endpoint answered.

trust: the downgrade a stolen token cannot avoid

The March 2026 axios compromise had a tell that no behavioral scan needed: the attacker had the maintainer's npm token but not the maintainer's CI, so where every recent legitimate release carried a provenance attestation, the malicious 1.14.1 and 0.30.4 carried none. That drop, a version resolving below the highest trust tier its package previously reached, is what npm/cli#9242 (59 👍, open since April 2026) and yarnpkg/berry#7101 ask their package managers to refuse. pnpm shipped it natively as trust-policy=no-downgrade in 10.21. npm and Yarn have not, and this command is that gate for their lockfiles (and pnpm's and bun's too):

npx npm-script-lens trust --fail-on-downgrade
npm-script-lens trust: provenance downgrade check (npm/cli#9242, trusted publisher > provenance > none)
checked 3 registry package(s)

TRUST DOWNGRADE (1)
  [email protected]  provenance -> none
    highest prior trust: provenance ([email protected], published 2025-11-04)
    resolved version has no attestations
    pnpm >= 10.21 would refuse this install under trust-policy=no-downgrade

That output is real, not staged: axios 1.13.3 (published 2026-01-25, still on the registry today) genuinely carries no attestations while 1.13.2 before it carried provenance. Exit code is 1 only under --fail-on-downgrade, and only when a downgrade is present.

The ladder matches #9242: trusted publisher > provenance > none. A trusted-publisher (OIDC) publish is recognized by the registry's _npmUser.trustedPublisher stamp on the version; provenance is dist.attestations on the version (the SLSA predicate rides along); absent attestations is none. One packument request per package answers all three tiers for every version at once, cached 24h alongside the other trust data.

What never fires a finding: packages that have never published with attestations (there is no ratchet to fall from), a package's first published version, versions published after the one you resolve (a later provenance adoption says nothing about your pin), git/remote-sourced dependencies (their history is not the registry's), and an unreachable registry, which warns once and exits 0 rather than failing your build on network weather. A previously unpublished version leaves a gap that is compared around, never counted as a downgrade. Deprecated versions still count toward the max.

audit runs the same check when you opt in, either with audit --fail-on-downgrade or with the policy key, and emits the finding in the report, --json (results[].trustDowngrade) and SARIF (rule trust-downgrade, level error, anchored to the package's lockfile line):

// script-lens.policy.json, npm/cli#9242's keys so your config carries over
// if npm ever ships trust-policy natively
{
  "trustPolicy": "no-downgrade",          // #9242: trust-policy (default "off")
  "trustPolicyExclude": ["[email protected]"],    // #9242: trust-policy-exclude[]
  "trustPolicyIgnoreAfter": 525600        // #9242: trust-policy-ignore-after (minutes)
}

trustPolicy: "no-downgrade" makes every audit surface the finding without changing any exit code; the exit only flips under --fail-on-downgrade, so no existing CI changes colour without opting in. trustPolicyExclude allows a named version despite its drop (a maintainer who genuinely moved off CI); trustPolicyIgnoreAfter retires prior evidence older than the window. The trust command also takes them directly as --exclude pkg@version and --ignore-after minutes.

Honest limitations. A package whose 0.x maintenance line is published by hand while the 1.x line uses CI will flag the 0.x versions, exactly as pnpm's trust-policy=no-downgrade refuses them (real example: axios 0.30.x); that is what trustPolicyExclude is for. And like the provenance identity check, this reads registry claims over TLS, it does not verify Sigstore signatures. It also cannot catch an attacker who compromises the CI itself and publishes with provenance, the ChainDrop shape; --cooldown and the identity diff are the tools for that half.

publish: will your release workflow survive January 2027?

The other side of the same coin: npm-script-lens guards the install side of your workflows; publish guards the publish side. The GitHub changelog of 2026-07-31 is explicit: "2FA-bypass tokens will also lose direct publish. Their publishing surface will reduce to reading private packages and staging a publish, which a maintainer approves with 2FA. We are targeting January 2027 for this update." Phase 1 already shipped on 2026-07-31, so publishing is the last thing those tokens can still do. If your release workflow does npm publish with NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}, it has an expiry date.

npx npm-script-lens publish            # classify every CI publish path + the migration patch
npx npm-script-lens publish --check    # CI: exit 1 when a path uses a long-lived token or is BROKEN
npx npm-script-lens publish --json     # { cliff, floors, counts, paths, repo, engines }
npx npm-script-lens publish --sarif f  # rules publish-token-cliff + publish-oidc-broken, workflow-line anchored

It reads .github/workflows/*.yml, .github/actions/**/action.yml, .gitlab-ci.yml and .circleci/config.yml (no network, no YAML dependency), finds npm publish, npm stage publish, pnpm publish, yarn npm publish, np, semantic-release, changesets/action and JS-DevTools/npm-publish, and classifies each path as exactly one of:

  • TRUSTED: an id-token grant (permissions: id-token: write or write-all; GitLab id_tokens with audience npm:registry.npmjs.org; CircleCI NPM_ID_TOKEN) and no token: already on trusted publishing (OIDC), survives the cliff;
  • STAGED: npm stage publish, where a maintainer approves with 2FA (npm stage approve <stage-id>), survives the cliff;
  • TOKEN: NODE_AUTH_TOKEN/NPM_TOKEN in the env, or an .npmrc write containing _authToken. Stops working around January 2027, and fails --check;
  • BROKEN: trusted publishing is granted, but the job's actions/setup-node is v6 or older and passes registry-url: https://registry.npmjs.org. Every setup-node release up to v6 answers that input by writing //registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN} into an .npmrc and exporting a dummy NODE_AUTH_TOKEN, so npm sees auth as configured and never starts the OIDC exchange, so the publish fails with ENEEDAUTH/E404 (npm/documentation#1960; fixed in setup-node v7.0.0). The official trusted-publishers docs still show the broken @v6 recipe. Fails --check; the report gives all three fixes (bump to @v7, drop registry-url:, or sed -i '/_authToken/d' "$NPM_CONFIG_USERCONFIG") and a job that already strips the line stays TRUSTED. An unresolvable setup-node ref (SHA pin, branch, expression) gets a ⚠️ note, never a downgrade;
  • UNKNOWN: a publish exists but the auth is ambiguous (third-party reusable workflows, both grant and token, neither visible): reported, never a failure.

A publish step hidden behind uses: ./.github/actions/release is followed into the composite action itself (and into local reusable workflows, nested up to 3 levels): the path anchors to the real action.yml line, an indented via .github/workflows/release.yml:11 (job release, step "Release") line shows the call chain, and the auth threads the way GitHub threads it: composite actions cannot declare permissions, so the id-token grant is always the calling job's, and a composite env: NODE_AUTH_TOKEN: ${{ inputs.npm-token }} is resolved through the calling step's with: map back to secrets.NPM_TOKEN. A publishing composite under .github/actions/ that no workflow in the repo references is still reported (UNKNOWN, since it may be called from another repo). Third-party actions (actions/checkout@v4) are never flagged.

Then it does the three checks no migration blog performs:

  1. Version floors, verbatim from docs.npmjs.com: trusted publishing "requires npm CLI version 11.5.1 or later and Node version 22.14.0 or higher"; staged publishing "requires npm CLI version 11.15.0 or later and Node version 22.14.0 or higher." A setup-node pin (or an engines.node minimum) below the floor is called out with exactly which fix it blocks. Migrating a workflow that's pinned to Node 20 fails at the first publish, so the pin bump is part of the fix.
  2. Runner eligibility. Trusted publishing supports only GitHub-hosted runners, GitLab.com shared runners and CircleCI cloud: "Self-hosted runners are not currently supported but are planned for future releases." A runs-on: self-hosted publish job gets trusted publishing marked UNAVAILABLE and is routed to the one path that survives there: npm stage publish + npm stage approve <stage-id>.
  3. The npmjs.com side, pre-filled. Trusted publishing also needs config on npmjs.com; publish emits the settings checklist filled in from your repo (org/user, repository, workflow filename with its extension, the environment name if the job declares one, and the allowed actions).

Real output for a workflow publishing with a token on Node 20:

publish paths (1)
  TOKEN     .github/workflows/release.yml:15  npm publish   [job release · ubuntu-latest]
            long-lived token: NODE_AUTH_TOKEN in the publish step env (line 17)

⛔ 1 TOKEN publish path. Direct token publishing stops working around January 2027.

fix for .github/workflows/release.yml:15, switch to trusted publishing (OIDC):
  add to the `release` job (or the workflow top level) in .github/workflows/release.yml:
    + permissions:
    +   id-token: write
  remove the token from the publish step (line 17):
    - env:
    -   NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
  ⚠️  node-version 20 (.github/workflows/release.yml:12) is below the Node 22.14.0 floor …

npmjs.com trusted-publisher settings (package Settings → Trusted publisher):
  GitHub organization or user: acme
  repository:                  widget
  workflow filename:           release.yml   (with its extension, exactly as on disk)
  environment:                 (the job declares none, leave blank)
  allowed actions:             npm publish   (also allow "npm stage publish" if you plan to stage releases)

doctor reports the same readiness mix (naming the setup-node ref on a BROKEN path), and the GitHub Action's publish-check input fails the job with an ::error and a publish-token-cliff / publish-oidc-broken SARIF result when a TOKEN or BROKEN path remains.

Release gates: who can publish today?

Auth answers "will this path publish after January 2027". It does not answer the ChainDrop question. On 2026-08-04 that worm published 2,234 poisoned versions across 444 npm package names by taking over a maintainer's GitHub account and letting each project's own release workflow build, sign and publish, with valid provenance, because the authorized build system produced it. So publish now reports two more facts per resolved path: the trigger (which workflow events reach the job, with file:line) and the gate (what human action, if any, stands between a commit and npm publish):

  • DANGEROUS: reachable from pull_request_target or workflow_run. crates.io removed both from Trusted Publishing (development update, 2026-01-21): "Both triggers have been involved in past CI security incidents, where attackers exploited workflow permissions to escalate access or obtain publishing credentials." Fails --check on its own.
  • REVIEWABLE: the job declares environment:, the one hook GitHub offers for required reviewers. PyPI's security model: "Dedicated environments allow for additional protections like required reviewers, which can be used to require manual approval for a workflow using the environment."
  • MANUAL: only workflow_dispatch and/or release reach the job.
  • TAG: push with a tags-only filter.
  • AUTO: a branch push, bare push, schedule or pull_request, with no environment. Any commit that lands publishes.
  • UNKNOWN: not determinable (e.g. on: workflow_call with no caller in this repo). Never affects the exit code.

The worked example, a release workflow publishing on every merge to main:

publish paths (1)
  TRUSTED   .github/workflows/release.yml:22  npm publish   [job release · ubuntu-latest]
            trusted publishing (OIDC): id-token: write granted (line 9), no
            token in the env
            trigger: push → branches: [main]   (.github/workflows/release.yml:3)
            gate:    AUTO: any commit that lands on main publishes to npm. The
                     job declares no environment:, so GitHub cannot require an
                     approval.
            fix:     add `environment: release` to job "release" (line 11) and
                     set required reviewers on it (PyPI: "Dedicated environments
                     allow for additional protections like required reviewers");
                     or move to `on: release: types: [published]`; or publish
                     with `npm stage publish` + `npm stage approve <stage-id>`.

(Reports reflow to your terminal width, so the wrapping above is what an 80-column terminal shows. Piped or redirected output is not reflowed.)

Add environment: release to that job and the gate reads REVIEWABLE. Honesty note: whether required reviewers are actually configured on an environment is a repository setting, not a file, so this tool cannot verify it; REVIEWABLE says "verify required reviewers are configured on it" rather than implying it checked. Triggers thread through local reusable workflows and composite actions (a path reached via .github/actions/release inherits the calling workflow's on:). GitLab jobs classify from environment:, when: manual and tag-only rules:/only:; CircleCI from an approval-type job upstream in the same workflow; anything else stays UNKNOWN, never a guess.

publish --check exits 1 on DANGEROUS (plus TOKEN and BROKEN, as before). --require-gate <none|tag|manual|environment> (default none) raises the bar: tag fails AUTO paths, manual also fails TAG, environment demands REVIEWABLE. SARIF gains publish-dangerous-trigger (error) and, under --require-gate, publish-ungated (warning), anchored to the trigger line.

To be clear about what is new here: zizmor's dangerous-triggers audit already flags pull_request_target and workflow_run generically, in any workflow. What zizmor (41 audits), poutine (13 rules) and octoscan do not do is attribute a trigger and a human approval gate to a resolved npm publish path, through composite actions and reusable workflows, with a registry-precedent fix ladder. That attribution is this release.

hooks: what runs when the folder is opened?

npm-script-lens covers three moments where code runs without you asking: install time (audit/allow), resolution time (sources), publish time (publish). The 2026-08-04 keyv/ChainDrop worm used a fourth: open time. Wiz's teardown says it plainly: "Persistence is attempted via Claude Code hooks and VS Code tasks.json", two separately-hashed setup.mjs payloads, one under .claude, one under .vscode. And the tarball half predates the worm: hijacked npm packages html-to-gutenberg and fetch-page-assets (2026-05-25) shipped a hidden VS Code task named eslint-check with "runOn": "folderOpen", firing when the package directory itself is opened as a workspace.

npx npm-script-lens hooks                  # scan the working tree (monorepo subdirs included)
npx npm-script-lens hooks --check          # CI: exit 1 at or above the --fail-on floor (default high)
npx npm-script-lens hooks --deps           # also scan every locked dependency's tarball
npx npm-script-lens hooks --json           # { findings, partial, caveats, deps }
npx npm-script-lens hooks --sarif hooks.sarif   # rule hook-auto-run, anchored to the real file:line

Two surfaces (one table row each in src/hooks.js, adding an editor is a one-file patch):

  • .vscode/tasks.json: any task whose runOptions.runOn is "folderOpen", with its label, command+args, type, and whether presentation.reveal: "silent" hides the terminal.
  • .claude/settings.json: a documented project-level, committable hooks location. Auto-firing events (SessionStart, Setup, InstructionsLoaded) tier at full strength; agent-triggered ones (PreToolUse, PostToolUse, …) are collected, tiered one level lower and labelled. The four non-command hook types (http, mcp_tool, prompt, agent, with command, the complete documented set) are reported, but never as command execution.

Both files permit comments and trailing commas, so the reader is a tolerant JSONC parser in the same spirit as the gyp lens: a file that will not parse is reported partial (with a raw-text hint when folderOpen or an auto event name appears in the bytes), never passed silently, never a crash. Every command string feeds the same shell-signal extraction and score() that audit applies to a lifecycle script, the risk ladder is not forked. Real output for a repo carrying both worm artifacts:

.vscode/tasks.json:4  HIGH  folderOpen task "eslint-check" → node .vscode/setup.mjs (silent)
.claude/settings.json:3  HIGH  SessionStart hook → node .claude/setup.mjs
2 open-time execution entries found (2 HIGH).

--deps additionally downloads every locked dependency's tarball (cached, like audit results) and scans it for shipped .vscode/.claude surfaces, a folderOpen task inside a package is a payload, not a team convention, so it is HIGH regardless of command.

The two surfaces gate very differently, and the tool prints each caveat next to its own surface rather than one softened blend:

VS Code: a .vscode/tasks.json finding means "this runs once you trust this folder and allow automatic tasks", not "this has run", VS Code 1.117 defaults task.allowAutomaticTasks to off with a one-time Allow/Disallow prompt, workspace settings can no longer define that key, and automatic tasks never run in an untrusted workspace. The residual gap (microsoft/vscode#309406) is that the prompt does not display the command it is about to allow.

Claude Code: a SessionStart finding means "this runs on your next session in this trusted folder", there is no hook review gate before a project .claude/settings.json command hook fires ("Claude Code doesn't use the same hook review gate as Codex", Datadog Security Labs, 2026-08).

Interpolations (${workspaceFolder}, ${CLAUDE_PROJECT_DIR}) are kept literal, never resolved. .claude/settings.local.json is out of scope, it is machine-local and gitignored, so it neither ships in a tarball nor arrives with a clone.

review: see what you're approving, not just its name

npm v12's own pending list stops at the script command:

$ npm approve-scripts --allow-scripts-pending
[email protected]   install: node install/check

What's inside install/check? npm can't tell you, the #1 complaint in the v12 migration discussion. review picks up exactly where npm stops:

npx npm-script-lens review                        # show every pending approval with evidence
npx npm-script-lens review --output-allowscripts  # …and write the decisions into package.json

For each package awaiting an allowScripts decision it shows the script command, the first 40 lines of the actual file the command runs (from the version-pinned registry tarball, or node_modules with --offline), the behavioral scan verdict with signals, the OSV malware check, and publisher trust:

── [email protected]  [🔴 HIGH]
   1.9y old · 75M dl/wk · 1 maintainer · no provenance
   OSV: no known malicious advisories
   install: node install/check
     exec: node-gyp rebuild --directory=src
     exec: require('child_process')
   ┌─ install/check.js (first 40 of 42 lines)
   │   1  // Copyright 2013 Lovell Fuller and others.
   │   2  // SPDX-License-Identifier: Apache-2.0
   …

The pending set comes from your own npm when it can answer: with npm ≥ 12, review runs npm install --dry-run --json and reads its unreviewedScripts, so what you review is literally what npm would block, even before a lockfile exists. On npm < 12 (or --offline) it computes the same set from the lockfile minus your allowScripts entries (bare-name and pinned keys both count, and false is a decision too, matching npm v12's semantics exactly).

--output-allowscripts merges version-pinned entries for every reviewed package into package.json, preserving existing decisions: SAFE/LOW default to true, HIGH/MEDIUM and OSV-flagged packages to false, so flip after reading the evidence. --json emits the whole review (pending, risk, content, suggested block) for scripting. NPM_SCRIPT_LENS_NPM overrides which npm the dry-run uses.

allow: pre-approve the safe packages, hold the risky ones, in any package manager

allow runs the scan and splits every package that has install-time scripts into two buckets, the ones behavioral analysis found harmless (SAFE/LOW) go straight into the allowlist; everything that spawns processes, reaches the network, is known-malicious, or couldn't be fetched (MEDIUM/HIGH) is held back in a _review list for a human. It emits the block in your package manager's native format, auto-detected from the lockfile, on stdout, with a one-line summary on stderr:

npx npm-script-lens allow                     # scan, print the native allowlist block + _review
npx npm-script-lens allow --write             # …and merge the auto-approved entries into the right file
npx npm-script-lens allow --manager pnpm      # force a manager instead of auto-detecting
npx npm-script-lens allow --input audit.json  # classify a saved `audit --json` result, no rescan

Every major package manager adopted the same "scripts are opt-in, keep an allowlist" model. allow writes each one's native format: same risk policy, same analysis, different file:

| manager | allowlist | file | allow --write target | |---|---|---|---| | npm 12 | allowScripts: { "[email protected]": true } | package.json | package.json | | pnpm 10.26+/11 | allowBuilds: { pkg: true } | pnpm-workspace.yaml | pnpm-workspace.yaml (comment-preserving) | | yarn Berry | dependenciesMeta.<pkg>.built: true | package.json | package.json + enableScripts: false in .yarnrc.yml | | bun | trustedDependencies: ["pkg"] | package.json | package.json |

// npm project → allowScripts (version-pinned)
{ "allowScripts": { "[email protected]": true }, "_review": ["[email protected]"] }
// pnpm project → allowBuilds (by name)
{ "allowBuilds": { "core-js": true }, "_review": ["[email protected]"] }
1 package auto-approved, 1 need manual review. (pnpm, allowlist in pnpm-workspace.yaml)   ← stderr

bun caveat (surfaced automatically): defining trustedDependencies replaces bun's built-in trusted list, so packages bun trusted by default (esbuild, sharp…) stop running scripts unless listed. yarn needs enableScripts: false to turn dependenciesMeta into an allowlist, and allow --write sets it for you.

CI guard

allow --ci-check runs no scan, it's a fast gate for CI. It exits 1 when all three are true: a workflow in .github/workflows/ runs npm install/npm i/npm ci, package.json has no allowScripts block, and the local npm is v12+ (probed via npm --version). That is exactly the combination where npm v12 will silently skip every dependency's install scripts and your build breaks with no obvious cause.

npx npm-script-lens allow --ci-check
# CI will break on npm v12: run lens allow to generate allowScripts block.  (exit 1)

Any one of those conditions being false, npm < 12, an existing allowScripts block, or no npm install in CI, passes with a one-line reason. To fix a failing check, run allow --write: it writes the auto-approved entries and leaves the _review packages out (writing them would be deciding for you, so they stay pending until a human looks).

Governance policy

By default allow/review/sync auto-approve SAFE/LOW behavioral risk. A script-lens.policy.json in the project root (or --policy <file>) turns that fixed heuristic into a team decision:

{
  "autoApprove": {
    "maxRisk": "LOW",            // approve up to this risk (SAFE|LOW|MEDIUM|HIGH)
    "denyCapabilities": ["net"], // never auto-approve a script that reaches the network…
    "minAgeDays": 30,            // …or a version published < 30 days ago (needs trust data)
    "requireProvenance": false,  // …or one without a provenance attestation
    "expectProvenance": {        // pin the attested build identity per package
      "keyv": "jaredwray/keyv:.github/workflows/release.yml"
    }
  },
  "waivers": {
    "sharp": { "allow": true, "reason": "vetted native build", "expires": "2027-01-01" }
  },
  "runtimeBootstrapPolicy": "fail" // exit audit 1 on a RUNTIME_BOOTSTRAP finding
}

Waivers are explicit human decisions that override the heuristic until they expire, an auditable record of why a risky package was trusted. With no policy file present, behavior is exactly the built-in default.

runtimeBootstrapPolicy: "fail" is the checked-in form of --fail-on-runtime-bootstrap: either arms the gate so audit exits 1 when a package installs another JavaScript runtime at install time. It defaults to "off", so no existing CI changes colour without opting in.

Be