Skip to content

fix: enforce minimumReleaseAge on existing lockfile entries#11583

Merged
zkochan merged 30 commits into
mainfrom
fix/revalidate-lockfile-minimum-release-age
May 16, 2026
Merged

fix: enforce minimumReleaseAge on existing lockfile entries#11583
zkochan merged 30 commits into
mainfrom
fix/revalidate-lockfile-minimum-release-age

Conversation

@ryo-manba

@ryo-manba ryo-manba commented May 11, 2026

Copy link
Copy Markdown
Member

Closes #10438.

What

Re-verify every entry in pnpm-lock.yaml against the policies the resolver chain was configured with — today: minimumReleaseAge in strict mode — right after the lockfile is loaded from disk and before any tarball is fetched. A locked version that fails the policy aborts the install with ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION; minimumReleaseAgeExclude is honored.

Why

The policy only fires while pnpm is choosing a version. Once a version is pinned in the lockfile — e.g. a developer disabled the policy locally and committed a fresh dependency, or a CI cache restored a stale lockfile — every later pnpm install (including --frozen-lockfile and pnpm fetch) installs it without re-checking, which defeats the supply-chain protection the setting is supposed to provide.

The threat model is a lockfile someone else resolved, not local resolution: local resolution is already covered by the resolver's own per-version filter. bun fixed the same shape of bug in oven-sh/bun#30526; this PR is the pnpm side.

How

The fix introduces a generic ResolutionVerifier abstraction in the resolver chain — each resolver factory can ship a sibling verifier factory, exactly the way each resolver ships a resolve function. Today there's one verifier (npm); the shape leaves room for future ones (jsr, attestation-based, etc.) without changing the install-side interface.

  • @pnpm/resolving.resolver-base exports the ResolutionVerifier / ResolutionVerification types — the shared contract.
  • @pnpm/resolving.npm-resolver exports createNpmResolutionVerifier. Returns undefined when no policy is active, so callers can cheaply decide whether to iterate at all. When active, it inspects each lockfile entry, handles minimumReleaseAgeExclude, routes through named-registry prefixes (built-ins like gh: merged in), and uses fetchFullMetadataCached to fetch full registry metadata — decoupled from the resolver pipeline so neither peekManifestFromStore nor abbreviated metadata can hide the publish timestamp.
  • @pnpm/resolving.default-resolver exports createResolutionVerifier, a combinator that asks each underlying verifier (today: npm) if it has work and returns undefined when none does. Designed so that adding more verifiers later doesn't change the install side.
  • @pnpm/installing.client exposes verifyResolution on Client, built from the same fetchFromRegistry / getAuthHeader the resolver chain already uses — no second fetcher is constructed.
  • @pnpm/store.connection-manager and @pnpm/testing.temp-store surface verifyResolution alongside the store controller they hand back, so it reaches mutateModules through the existing plumbing.
  • @pnpm/installing.deps-installer gains one option on StrictInstallOptions: verifyResolution?: ResolutionVerifier. mutateModules invokes verifyLockfileResolutions(ctx.wantedLockfile, opts.verifyResolution) once, right after getContext returns the on-disk lockfile and before any path branches. When the verifier is undefined, the call is a no-op. The iteration is policy-neutral: dedupes by (name, version), applies pLimit(16), sorts violations stably, caps the printed list at 20 with an …and N more summary, throws a PnpmError carrying the verifier-supplied error code.

The error includes a recovery hint that points at pnpm clean --lockfile followed by pnpm install — the safe way to throw away a poisoned lockfile and rebuild from fresh resolution.

Tests

  • 9 unit tests for verifyLockfileResolutions against a mock ResolutionVerifier — dedup, aggregation, stable ordering, the 20-entry cap, no-op behavior, the verifier-supplied error code surfacing in PnpmError.
  • 13 integration tests in installing/deps-installer/test/install/minimumReleaseAge.ts via the real install() entry — testDefaults() wires verifyResolution from createTempStorecreateClient, so the npm verifier runs end-to-end at the install boundary. Covers the rejection scenario, minimumReleaseAgeExclude, the strict-mode toggle, the existing minimumReleaseAge resolver-side suite, and a pnpm add scenario where a pre-existing entry would otherwise survive resolution.
  • 3 e2e tests in pnpm/test/install/minimumReleaseAge.ts against the bundled CLI: rejection path with the right ERR_PNPM_* code and pnpm clean --lockfile hint in output, minimumReleaseAgeExclude honored, and the strict-off path (which now requires an explicit minimumReleaseAgeStrict: false since the config reader auto-enables strict mode when minimumReleaseAge is set).
  • Existing frozenLockfile suite (12 tests) and npm-resolver suite (179 tests) still pass.

Summary by CodeRabbit

  • New Features
    • Added lockfile validation during installation that re-checks package release dates against the configured minimumReleaseAge policy. When strict mode is enabled, installations abort with ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION if locked entries violate the policy. The minimumReleaseAgeExclude option is honored during validation.

@coderabbitai

coderabbitai Bot commented May 11, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR implements a pluggable resolution verifier interface that allows registry-specific validators to re-check lockfile entries against upstream policies. Starting with npm minimumReleaseAge, the changes add a fail-closed verifier that fetches full registry metadata with cache-aware conditional GET support, integrates a lockfile revalidation routine into the install flow, and wires the verifier through the store, client, and all install command entry points.

Changes

Lockfile Resolution Verification

Layer / File(s) Summary
Resolution verification contract
resolving/resolver-base/src/index.ts
New ResolutionVerification and ResolutionVerifier types define how verifiers report lockfile entry outcomes (pass/fail with optional code and reason).
NPM verifier implementation
resolving/npm-resolver/src/createNpmResolutionVerifier.ts
createNpmResolutionVerifier enforces minimumReleaseAge on npm-registry resolutions using longest-prefix registry routing, exclusion policies, inflight deduplication of metadata fetches, and fail-closed semantics.
Fetch full metadata helper
resolving/npm-resolver/src/fetchFullMetadataCached.ts
fetchFullMetadataCached performs conditional GETs, reads/writes the on-disk full-metadata mirror, and returns package metadata for verifier use.
Resolver helpers exported
resolving/npm-resolver/src/pickPackage.ts
Internal pickPackage helpers (encodePkgName, getPkgMirrorPath, prepareJsonForDisk, loadMetaHeaders, loadMeta, saveMeta) are exported for reuse by the npm verifier's metadata caching layer.
Default resolver verifier factory
resolving/default-resolver/src/index.ts, resolving/default-resolver/package.json, resolving/default-resolver/tsconfig.json
createResolutionVerifier orchestrates fetch config, auth-header routing, and npm-verifier creation; adds dependency on @pnpm/network.auth-header and TypeScript reference adjustments.
Client integration
installing/client/src/index.ts
Client interface gains optional verifyResolution field; createClient constructs and returns it via createResolutionVerifier(...).
Store controller integration
store/connection-manager/src/createNewStoreController.ts, store/connection-manager/src/index.ts, store/connection-manager/package.json, store/connection-manager/tsconfig.json
Introduces StoreControllerHandle interface with optional verifyResolution; createNewStoreController forwards minimumReleaseAge options into createClient and returns the handle including verifyResolution.
Lockfile revalidation routine
installing/deps-installer/src/install/verifyLockfileResolutions.ts, installing/deps-installer/test/install/verifyLockfileResolutions.ts
verifyLockfileResolutions iterates lockfile packages, deduplicates by (name, version), runs bounded-concurrency verification, collects deterministic violation details with capped output, and throws a PnpmError using the first violation’s code. Tests cover no-op, success, single/multi-failure, deduplication, and output truncation.
Install options and contracts
installing/deps-installer/src/install/extendInstallOptions.ts, installing/commands/src/recursive.ts
StrictInstallOptions adds optional verifyResolution; RecursiveOptions updated to accept the verifier for workspace/per-project flows.
Install flow integration
installing/deps-installer/src/install/index.ts, installing/commands/src/installDeps.ts, installing/commands/src/fetch.ts, installing/commands/src/import/index.ts, installing/commands/src/recursive.ts, installing/commands/src/remove.ts
All command handlers source store.verifyResolution and pass it into installer options; mutateModules invokes verifyLockfileResolutions immediately after the wanted lockfile is available and before tarball installation, with reporter teardown centralized.
Test utilities
installing/deps-installer/test/utils/testDefaults.ts, testing/temp-store/src/index.ts
testDefaults accepts minimumReleaseAge, minimumReleaseAgeStrict, and minimumReleaseAgeExclude and forwards them to temp store client config; createTempStore returns verifyResolution.
minimumReleaseAge integration tests
installing/deps-installer/test/install/minimumReleaseAge.ts, pnpm/test/install/minimumReleaseAge.ts
New and extended tests validate strict-mode revalidation during install/add, exclusion handling (including transitive), and non-strict default behavior using public registry metadata.
Build configuration and docs
testing/temp-store/package.json, store/connection-manager/package.json, .changeset/revalidate-minimum-release-age.md, .changeset/cache-aware-minimum-release-age-gate.md
Adds workspace references/dependencies to resolving.resolver-base, updates tsconfig references, and changesets document revalidation and cache-aware verifier behavior.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

  • pnpm/pnpm#11436: Related to how minimumReleaseAgeStrict defaults; connected to strict-mode behavior exercised by this PR.
  • pnpm/pnpm#11618: Also touches time-based/npm resolution metadata handling used by minimumReleaseAge checks.

Suggested reviewers

  • zkochan

Poem

🐰 A rabbit hops through lockfiles with care,

Verifying each version with time-aware flair,
Cache-aware hops skip the registry dance,
Fail-closed gates make each install take a stance,
Strict mode nibbles on young releases — beware!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.81% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly describes the main change: enforcing minimumReleaseAge validation on existing lockfile entries, which is the core feature of this PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/revalidate-lockfile-minimum-release-age

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@ryo-manba ryo-manba marked this pull request as ready for review May 12, 2026 07:42
@ryo-manba ryo-manba requested a review from zkochan as a code owner May 12, 2026 07:42
Copilot AI review requested due to automatic review settings May 12, 2026 07:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR closes #10438 by enforcing minimumReleaseAge against existing pnpm-lock.yaml entries (not only during version resolution). It adds a pre-install “lockfile revalidation” gate that fetches full registry metadata for every npm-registry lockfile entry and aborts the install if any locked version is newer than the configured cutoff (honoring minimumReleaseAgeExclude).

Changes:

  • Add revalidateLockfileAgainstMinimumReleaseAge() and run it in the headless/frozen install path before tarballs are installed (only when minimumReleaseAgeStrict is enabled).
  • Add createFullMetadataLookup() to fetch full registry metadata (including time) independently of resolver/store fast paths.
  • Adjust CLI install flow to skip optimisticRepeatInstall when the gate is active, and add unit/integration coverage for the bypass scenario.

Reviewed changes

Copilot reviewed 10 out of 11 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
pnpm-lock.yaml Updates workspace links for new deps-installer dependencies.
installing/deps-installer/tsconfig.json Adds TS project references needed by new deps-installer imports.
installing/deps-installer/test/install/revalidateLockfileMinimumReleaseAge.ts New unit tests for lockfile revalidation logic and fail-closed behavior.
installing/deps-installer/test/install/minimumReleaseAge.ts New integration tests proving existing-lockfile enforcement and exclude behavior.
installing/deps-installer/src/install/revalidateLockfileMinimumReleaseAge.ts Implements lockfile-wide minimumReleaseAge enforcement with exclusions/deduping.
installing/deps-installer/src/install/index.ts Invokes the revalidation gate before headlessInstall when strict mode is on.
installing/deps-installer/src/install/extendInstallOptions.ts Extends options surface with minimumReleaseAgeStrict and documents cacheDir.
installing/deps-installer/src/install/createFullMetadataLookup.ts Adds full-metadata manifest lookup used by the lockfile gate.
installing/deps-installer/package.json Adds new internal dependencies required for the gate/lookup.
installing/commands/src/installDeps.ts Skips optimistic-repeat install fast-path when strict gate is active.
.changeset/revalidate-minimum-release-age.md Changeset documenting the new enforcement behavior and error code.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +48 to +56
// Pre-compute the (origin → registry URL) lookup once. Named-registry
// entries are absolute URLs; their tarballs always live under that origin.
// We use this to recover the right registry for non-scope-routed packages
// when the lockfile tells us where the tarball came from.
const namedRegistryOrigins = new Map<string, string>()
for (const url of Object.values(opts.namedRegistries ?? {})) {
try {
namedRegistryOrigins.set(new URL(url).origin, url)
} catch {
Comment on lines +67 to +78
const result = await fetchMetadataFromFromRegistry(fetchOpts, name, {
registry,
authHeaderValue: getAuthHeaderValueByURI(registry),
fullMetadata: true,
})
if ('notModified' in result && result.notModified) {
throw new PnpmError(
'REVALIDATE_NOT_MODIFIED_WITHOUT_CACHE',
`Registry returned 304 for ${name} without an existing cache to refresh.`
)
}
return result.meta
Comment on lines +42 to +47
const fetchOpts = {
fetch: fetchFromRegistry,
retry: opts.retry ?? {},
timeout: opts.timeout ?? 60_000,
fetchWarnTimeoutMs: 10_000,
}
Comment on lines +76 to +81
* The directory pnpm uses for cached package metadata.
* Required by the lockfile minimumReleaseAge revalidation pass to spin up a
* dedicated full-metadata fetcher; in practice every install caller (CLI,
* test harness, programmatic API) already supplies this value, so it's
* declared optional here to avoid breaking callers that haven't yet plumbed
* the field into their `InstallOptions` type.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
installing/commands/src/installDeps.ts (2)

166-174: 💤 Low value

Consider simplifying the gate check.

The Boolean() wrapper is unnecessary since the && operator already produces a boolean result.

♻️ Simplified version
- const minimumReleaseAgeGateActive = Boolean(opts.minimumReleaseAge) && opts.minimumReleaseAgeStrict === true
+ const minimumReleaseAgeGateActive = !!opts.minimumReleaseAge && opts.minimumReleaseAgeStrict === true
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@installing/commands/src/installDeps.ts` around lines 166 - 174, The Boolean()
wrapper around opts.minimumReleaseAge is redundant; simplify the gate by
assigning minimumReleaseAgeGateActive = opts.minimumReleaseAge &&
opts.minimumReleaseAgeStrict === true (or use a strict boolean cast if desired)
and update the if condition that references minimumReleaseAgeGateActive
accordingly; touch the constant minimumReleaseAgeGateActive and the if statement
containing opts.update, opts.dedupe, params.length, opts.optimisticRepeatInstall
to reflect the simplified boolean expression.

166-173: 💤 Low value

Comment is clear but verbose.

The 13-line comment block thoroughly explains the rationale, but consider condensing to 4-5 lines for better readability while preserving the key points: (1) optimistic-repeat skips revalidation, (2) strict mode requires it, (3) gated to preserve non-strict default.

📝 More concise version
-  // The optimistic-repeat fast path declares the install "up to date" based on
-  // lockfile vs node_modules state alone and never reaches the install function.
-  // When the lockfile minimumReleaseAge gate is active (strict mode) we need
-  // the revalidation pass inside tryFrozenInstall to run, so skip the fast
-  // path. Gated on strict mode so the built-in default (non-strict, 1 day)
-  // keeps the optimisticRepeatInstall optimization intact for everyone who
-  // hasn't explicitly opted in.
+  // Skip optimistic-repeat when strict minimumReleaseAge is active to ensure
+  // the lockfile revalidation gate in tryFrozenInstall runs. Gated on strict
+  // mode to preserve the optimization for the built-in default (non-strict, 1d).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@installing/commands/src/installDeps.ts` around lines 166 - 173, Condense the
long comment above the minimumReleaseAgeGateActive declaration to a 4–5 line
summary that states: optimistic-repeat skips revalidation, strict mode
(opts.minimumReleaseAgeStrict) requires the revalidation pass inside
tryFrozenInstall, and the gate (opts.minimumReleaseAge) is applied so the
default non-strict behavior keeps the optimisticRepeatInstall optimization. Keep
the variable name minimumReleaseAgeGateActive and the references to
opts.minimumReleaseAge and opts.minimumReleaseAgeStrict in the comment so
reviewers can quickly map the rationale to the code.
installing/deps-installer/src/install/index.ts (1)

908-943: 💤 Low value

Consider condensing the comment block.

The 35-line comment thoroughly explains the rationale, but consider reducing to ~15 lines for better maintainability. Key points to preserve: (1) why the gate is needed (resolution-skip bypasses policy), (2) fail-closed design, (3) runs even for ignorePackageManifest, (4) gated on strict mode.

📝 More concise version
+    // Re-apply minimumReleaseAge policy to the lockfile before installation.
+    // The resolution-skip optimization above bypasses the publish-date check
+    // in pickPackage, allowing a poisoned lockfile to install fresh versions.
+    // This gate validates every npm-resolved entry using full registry metadata
+    // (see issue `#10438`, mirrors bun's oven-sh/bun#30526).
+    //
+    // Runs even for `ignorePackageManifest` paths (e.g. `pnpm fetch`) since
+    // those install straight from the lockfile. Gated on minimumReleaseAgeStrict
+    // so the built-in default (non-strict, 1d) doesn't fire for all users.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@installing/deps-installer/src/install/index.ts` around lines 908 - 943, The
large 35-line comment above the minimum-release-age check should be condensed to
~15 lines preserving the key points: that the resolution-skip optimization can
bypass the minimumReleaseAge policy, so we re-check the lockfile using a full
registry metadata lookup to enforce a fail-closed gate; this runs even when
ignorePackageManifest is set (e.g., pnpm fetch) because installs from the
lockfile are the attack vector; and the gate is only applied when
opts.minimumReleaseAge && opts.minimumReleaseAgeStrict. Update the comment
immediately above the if (opts.minimumReleaseAge &&
opts.minimumReleaseAgeStrict) block (near createFullMetadataLookup and
revalidateLockfileAgainstMinimumReleaseAge and referencing ctx.wantedLockfile)
to the concise form, keeping those four points and removing the extra
historical/linked-issue detail.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@installing/deps-installer/src/install/revalidateLockfileMinimumReleaseAge.ts`:
- Line 81: Add a brief inline comment above the "const limit =
pLimit(opts.concurrency ?? 16)" line explaining that the hard-coded default of
16 is intentional and chosen to match pnpm's package-requester concurrency
floor; reference "pLimit" and the "limit" variable so future maintainers
understand the origin and purpose of the default value and that it should remain
aligned with pnpm behavior.

---

Nitpick comments:
In `@installing/commands/src/installDeps.ts`:
- Around line 166-174: The Boolean() wrapper around opts.minimumReleaseAge is
redundant; simplify the gate by assigning minimumReleaseAgeGateActive =
opts.minimumReleaseAge && opts.minimumReleaseAgeStrict === true (or use a strict
boolean cast if desired) and update the if condition that references
minimumReleaseAgeGateActive accordingly; touch the constant
minimumReleaseAgeGateActive and the if statement containing opts.update,
opts.dedupe, params.length, opts.optimisticRepeatInstall to reflect the
simplified boolean expression.
- Around line 166-173: Condense the long comment above the
minimumReleaseAgeGateActive declaration to a 4–5 line summary that states:
optimistic-repeat skips revalidation, strict mode (opts.minimumReleaseAgeStrict)
requires the revalidation pass inside tryFrozenInstall, and the gate
(opts.minimumReleaseAge) is applied so the default non-strict behavior keeps the
optimisticRepeatInstall optimization. Keep the variable name
minimumReleaseAgeGateActive and the references to opts.minimumReleaseAge and
opts.minimumReleaseAgeStrict in the comment so reviewers can quickly map the
rationale to the code.

In `@installing/deps-installer/src/install/index.ts`:
- Around line 908-943: The large 35-line comment above the minimum-release-age
check should be condensed to ~15 lines preserving the key points: that the
resolution-skip optimization can bypass the minimumReleaseAge policy, so we
re-check the lockfile using a full registry metadata lookup to enforce a
fail-closed gate; this runs even when ignorePackageManifest is set (e.g., pnpm
fetch) because installs from the lockfile are the attack vector; and the gate is
only applied when opts.minimumReleaseAge && opts.minimumReleaseAgeStrict. Update
the comment immediately above the if (opts.minimumReleaseAge &&
opts.minimumReleaseAgeStrict) block (near createFullMetadataLookup and
revalidateLockfileAgainstMinimumReleaseAge and referencing ctx.wantedLockfile)
to the concise form, keeping those four points and removing the extra
historical/linked-issue detail.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: daaa367a-88a9-45a7-bf94-1553ae9d9be0

📥 Commits

Reviewing files that changed from the base of the PR and between 91b0e64 and b5c791c.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (10)
  • .changeset/revalidate-minimum-release-age.md
  • installing/commands/src/installDeps.ts
  • installing/deps-installer/package.json
  • installing/deps-installer/src/install/createFullMetadataLookup.ts
  • installing/deps-installer/src/install/extendInstallOptions.ts
  • installing/deps-installer/src/install/index.ts
  • installing/deps-installer/src/install/revalidateLockfileMinimumReleaseAge.ts
  • installing/deps-installer/test/install/minimumReleaseAge.ts
  • installing/deps-installer/test/install/revalidateLockfileMinimumReleaseAge.ts
  • installing/deps-installer/tsconfig.json
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Agent
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{js,ts,tsx,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{js,ts,tsx,jsx}: Use trailing commas in code following Standard Style with modifications
Prefer functions over classes
Declare functions after they are used, relying on hoisting
Functions should have no more than two or three arguments; use a single options object for functions needing more parameters
Organize imports in order: standard libraries, external dependencies (sorted alphabetically), then relative imports

Files:

  • installing/deps-installer/src/install/extendInstallOptions.ts
  • installing/deps-installer/src/install/createFullMetadataLookup.ts
  • installing/deps-installer/test/install/minimumReleaseAge.ts
  • installing/commands/src/installDeps.ts
  • installing/deps-installer/src/install/revalidateLockfileMinimumReleaseAge.ts
  • installing/deps-installer/test/install/revalidateLockfileMinimumReleaseAge.ts
  • installing/deps-installer/src/install/index.ts
🧠 Learnings (1)
📚 Learning: 2026-05-05T23:03:04.286Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11479
File: __utils__/scripts/package.json:6-9
Timestamp: 2026-05-05T23:03:04.286Z
Learning: The pattern cross-env NODE_OPTIONS="$NODE_OPTIONS ..." in package.json scripts is an established convention in the pnpm/pnpm repository and is used across many packages (e.g., fs/hard-link-dir, worker, __utils__/scripts). Do not flag this as a cross-platform issue in individual files; if a change is needed, apply it as a repo-wide change in a separate PR. Scope this guidance to all package.json files in the repo; use the minimatch pattern '**/package.json' to identify relevant files and review changes at the repository level rather than per-file.

Applied to files:

  • installing/deps-installer/package.json
🔇 Additional comments (9)
installing/deps-installer/src/install/revalidateLockfileMinimumReleaseAge.ts (2)

63-67: LGTM: Clean deduplication strategy.

The Map-based deduplication correctly handles peer-dependency and patch_hash suffixes in depPath, ensuring each (name, version) pair is checked at most once. The tarballUrl preservation for named-registry routing is a thoughtful detail.


98-105: Robust handling of invalid timestamps.

The Number.isNaN(ts) check correctly guards against invalid Date objects (e.g., new Date('invalid') produces a Date with NaN timestamp). This fail-closed behavior aligns with the documented design.

installing/deps-installer/src/install/createFullMetadataLookup.ts (3)

72-77: Appropriate error for unexpected 304 response.

The notModified error handling is correct: when fetching with fullMetadata=true and no existing cache, a 304 Not Modified response is unexpected and likely indicates a registry or fetch-layer bug. Throwing REVALIDATE_NOT_MODIFIED_WITHOUT_CACHE ensures this is surfaced rather than silently failing.


63-63: Cache key includes null byte separator.

Using \x00 (null byte) as the separator between registry and name is a solid choice to prevent collision issues (e.g., reg1\x00foo/bar vs reg1/foo\x00bar are unambiguous). This is a common pattern for composite cache keys.


54-59: Defensive URL parsing with silent fallback.

The try-catch around new URL(url) appropriately handles malformed named-registry URLs by silently continuing. The comment correctly notes that pickRegistryForPackage will surface its own error if the malformed URL is actually used. This avoids redundant error messages during pre-computation.

installing/deps-installer/src/install/index.ts (1)

933-943: LGTM: Proper integration of revalidation gate.

The gate correctly:

  • Checks both minimumReleaseAge and minimumReleaseAgeStrict before firing
  • Creates a fresh full-metadata lookup with all dispatcher options
  • Passes through minimumReleaseAge and minimumReleaseAgeExclude settings
  • Runs after the frozen-install feasibility check but before headlessInstall

This placement ensures the policy is enforced for frozen installs while avoiding redundant work when a full resolution will happen anyway.

installing/deps-installer/test/install/minimumReleaseAge.ts (2)

2-2: Import extension looks correct.

Bringing install into this suite is a good fit for validating the new lockfile revalidation path end-to-end in tests.


103-151: Good strict-mode lockfile revalidation coverage.

These tests cleanly verify the three key behaviors: strict-mode enforcement on existing lockfile entries, minimumReleaseAgeExclude handling (including transitives), and inert behavior when strict mode is off.

installing/deps-installer/test/install/revalidateLockfileMinimumReleaseAge.ts (1)

26-282: Excellent targeted test matrix for revalidation behavior.

This suite thoroughly exercises success, violation, exclusion, skip, fail-closed, and dedupe paths for revalidateLockfileAgainstMinimumReleaseAge with deterministic assertions.

Comment thread installing/deps-installer/src/install/revalidateLockfileMinimumReleaseAge.ts Outdated
Copilot AI review requested due to automatic review settings May 12, 2026 10:25

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 11 changed files in this pull request and generated no new comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

@zkochan

zkochan commented May 14, 2026

Copy link
Copy Markdown
Member

This will make install slower though. Are we sure it is a good default to have it on?

@theoludwig

Copy link
Copy Markdown

This will make install slower though. Are we sure it is a good default to have it on?

Security > Performance.

If a version is younger than minimum release age, it should not be installed, even if it's already in the lockfile. Better to make the installation fail.

@zkochan

zkochan commented May 14, 2026

Copy link
Copy Markdown
Member

ok, but we also have a special command for security: audit. So this could be part of pnpm audit.

@Lisenish

Lisenish commented May 15, 2026

Copy link
Copy Markdown

Sorry for chiming in for a bit... 🙇

ok, but we also have a special command for security: audit. So this could be part of pnpm audit.

Sorry, but I'm not sure how pnpm audit helps for this issue.
I think it has been discussed extensively in #10438, but let me reiterate a little bit here.

Let's imagine situation:

  1. We have minimumReleaseAge setting with 3 days config (minimumReleaseAgeStrict is true by default)
  2. There's dependency A@1.0.0 in our project. It also has allowBuilds entry because it's a known dependency for us and we know it should run postinstall script
  3. This dependency is compromised (happens almost weekly nowadays 🥲), hacker replaces postinstall script, releases A@1.0.1 which exfiltrates all env vars, secrets etc..
  4. Maybe maliciously or maybe someone without full understanding did forced install or replaced lockfile version to use A@1.0.1 locally
  5. They push it to the company repo and open PR
  6. CI runs and install everything without any issues despite our minimumReleaseAge / minimumReleaseAgeStrict settings. All CI secrets are exfiltrated here...
  7. This PR can be even MERGED to master => all the people who pull it and do install are pwned as well.

I'm sorry but this basically renders the whole minimumReleaseAge/minimumReleaseAgeStrict useless since they're clearly do not protect nor CI / nor other people from supply chain attack here at all.

Regarding performance tradeoff: I think we can make the check depend on minimumReleaseAgeStrict option?
If people want to choose the speed in this tradeoff - they can always set minimumReleaseAgeStrict: false or don't set minimumReleaseAge at all. I don't think it's correct to make this choice for everyone as a tool author.

@gsouf

gsouf commented May 15, 2026

Copy link
Copy Markdown

This will make install slower though. Are we sure it is a good default to have it on?

If it's fast, it means it's even faster for a corrupted package to hit your machine. Nobody wants that to happen. I don't need speed, I need security.

This is what the industry should have been doing ten years ago already.

It should be the default for EVERYONE, even the beginners who still haven't received the proper security education, and who see a lot of shinny objects in the registry they want to try.

Disabling it should be INTENTIONAL

@SecretX33

SecretX33 commented May 15, 2026

Copy link
Copy Markdown

When it comes to the tradeoff between security and speed, I usually make the default behavior the secure option.

If it makes sense for the situation, I sometimes also provide an explicit optional "foot-gun mode" that users can opt into if they really want the extra speed, accepting that it may let them shoot themselves in the foot if they are not extra careful.

This will make install slower though. Are we sure it is a good default to have it on?

@ryo-manba can we have some benchmarks of this PR on a project with 10, another with 25, and a third with 50 dependencies? I'm really curious to see the real performance impact of this change.

Run pnpm install on these three projects in these scenarios:

  1. Only package.json present, no node_modules or pnpm-lock.yaml
  2. Only package.json and pnpm-lock.yaml present, no node_modules
  3. All three package.json, pnpm-lock.yaml and node_modules present

@zkochan

zkochan commented May 15, 2026

Copy link
Copy Markdown
Member

CI runs and install everything without any issues despite our minimumReleaseAge / minimumReleaseAgeStrict settings. All CI secrets are exfiltrated here...

Regardless of this change, your CI is clearly misconfigured if your secrets can be exfiltrate at this point.

pnpm audit should be a required check in every PR. If it isn't, you can merge dependencies with vulnerabilities. So why does audit not work?

It should be the default for EVERYONE, even the beginners who still haven't received the proper security education, and who see a lot of shinny objects in the registry they want to try.

OK, well can we at least think about ways of doing it with no performance regression? Do you realize that in CI this means an additional request for every single package in the lockfile? Sometimes two requests, because the time field is not part of the abbreviated package document, so if the package is recently modified, we request the full metadata, which is much bigger in size.

can we have some benchmarks of this PR on a project with 10, another with 25, and a third with 50 dependencies? I'm really curious to see the real performance impact of this change.

These are completely unrelevant tests. 2000-3000 packages is usual for a project. I'd call that a small project.

@zkochan

zkochan commented May 15, 2026

Copy link
Copy Markdown
Member

If we are going to add this, I have the following requirements to maximize performance:

  1. Have a global per-lockfile cache.

Store one record per lockfile path at <cacheDir>/minimum-release-age-verified.json:

{
  "/absolute/path/to/pnpm-lock.yaml": {
    "lockfileHash": "<sha256 of pnpm-lock.yaml>",
    "minimumReleaseAge": 1440,
    "verifiedAt": "2026-05-14T…",
    "lockfileFileSize": 154,
    "lockfileMtimeNs": "1736245123000000000",
    "lockfileInode": 12345
  }
}

On install:

  • Look up the record by lockfile absolute path. If there is no record → run the gate.
  • Stat the lockfile. If size, mtime, and inode all match the cached values → trust it, skip the hash entirely. Zero registry calls.
  • If size differs → guaranteed miss, run the gate.
  • Otherwise (size matches but mtime/inode differ — typical after a fresh CI checkout where the cache was restored) → hash the file. If the hash matches lockfileHash and current minimumReleaseAge <=
    cached minimumReleaseAge → skip the gate, and refresh the stat fields on the record so future installs hit the fast path.
  • Otherwise run the gate and record the new hash + stat fields.

lockfileMtimeNs and lockfileInode are only meaningful on the same machine; CI runners reset them on every checkout. That is fine: in CI we fall back to the hash check, which is portable. The stat-cache
layer is the local-dev fast path; the hash is the source of truth.

We will recommend uploading this file as part of the CI cache.

  1. Try the attestation endpoint before fetching full metadata.

When verifying dates, if there is no package document in the local cache, first try to request the attestation from the registry (e.g. https://registry.npmjs.org/-/npm/v1/attestations/pnpm@11.1.1). This
is a much smaller payload than the full metadata document, but it requires Sigstore signature verification and is only available for packages that were published with provenance — so fall back to
fetching full metadata for unattested packages.

If we will do it, might as well add other checks of the lockfile if needed.


Something to note: public benchmarks will show pnpm significantly slower in the cold-cache, up-to-date-lockfile scenario. There is no way around per-package round trips on a fresh CI runner with no
restored cache; the optimizations above target steady-state, not cold start.

@zkochan

zkochan commented May 15, 2026

Copy link
Copy Markdown
Member

One speed optimisation that can be done is fetching packages while verifying the lockfile. That means an immature package might get fetched to the store if it gets downloaded before the lockfile is completely verified. I don't know if that's ok.

@zkochan

zkochan commented May 15, 2026

Copy link
Copy Markdown
Member

Also, seems like Yarn has something like this: https://yarnpkg.com/features/security#hardened-mode

I don't know what they check. Probably the correctness of the tarball URLs. We could check that too when they are present.

Also, what to do when the lockfile gets changed? For instance, a repository is cloned and the first command is not pnpm install but pnpm add eslint. In this case should we first validate the lockfile and then make the updates? I guess we should. So almost all commands will be slower except fresh install.

@gsouf

gsouf commented May 16, 2026

Copy link
Copy Markdown

@zkochan

OK, well can we at least think about ways of doing it with no performance regression? Do you realize that in CI this means an additional request for every single package in the lockfile? Sometimes two requests, because the time field is not part of the abbreviated package document, so if the package is recently modified, we request the full metadata, which is much bigger in size.

That's exactly what I implemented by myself in my CI to get security first, after I realized that pnpm was not doing it for me. We have a cult of running CI as fast as possible at any cost, which is dangerous imo.

And I'd rather depend on a community reviewed and trusted implementation rather than on my clumsy take on it.

As you mentioned, we can find ways to make it faster, which would be great, but that should never come at the expense of skipping checks we've configured in pnpm.
If the community works together on it, I'm pretty sure we can get find something.

To be honest with you, when I first took a look at the problem I was surprised that we have to fetch again the metadata for each package individually, given it's already in the lockfile and validated with a checksum, and also that the common use case is to fetch many packages at once.


I'm really not aware of all the deep technical aspects of this, so I might be overlooking some important details, and I'm not the best person to review it. So please bear with me if I'm being too naive.

But given we already have a checksum in the lockfile, having metadata cached locally is what I was expecting to see in the very first place.

So I think that the local cache makes sense.

But would that become a possible attack vector then? Injecting a poisoned cache file before resolution? Is it relevant to consider this as a threat?

Also - if an affected repository/version is deleted from npm after getting caught. It would still live in the cache file despite being removed from NPM. Is that also a possible security issue?


If we admit that we could bring changes at the repository level, which of course would be a whole other project and bigger in scope, would that help in terms of speed if, at the registry level, we were able to send a single http request to fetch the metadata of many packages at once? I opened a discussion here https://github.com/orgs/community/discussions/196051

Also, once again just throwing ideas, but would we have a way to have the date and other metadata without having to download the metadata at all, directly encoded into the source or something?

@gsouf

gsouf commented May 16, 2026

Copy link
Copy Markdown

@zkochan

pnpm audit should be a required check in every PR. If it isn't, you can merge dependencies with vulnerabilities. So why does audit not work?

pnpm audit would work - the problem is that many people are not even aware of it.. They just find an outdated or poor guide online on how to get started with nodejs, how to setup a CI, they go with it and they even consider there is a risk. And what's surprising is the amount of devs who, despite having years of experience, are still not concerned with this.

The other problem is that install will run these checks on the first install - but then never again when it comes from the lockfile.
I'm paranoid when it comes to security, which led me to figure out this issue, but not immediately. So there was a period of time during which I was using pnpm thinking I was safe, and it would always run the checks when it was actually not.
Just to say that even experienced developers who are proactive about security get confused with the behavior on the install command when it comes to checking for configured checks.

@zkochan

zkochan commented May 16, 2026

Copy link
Copy Markdown
Member

But would that become a possible attack vector then? Injecting a poisoned cache file before resolution? Is it relevant to consider this as a threat?

It can, if poisoned cache will be uploaded from a fork repository. See the recent tanstack attack.

I am not saying local cache will not be used during lockfile verification but even reading 2K metadata files from cache will make install slower. Hence, the idea of caching the lockfile verification result is a good call today.

ryo-manba added 3 commits May 16, 2026 14:14
When pnpm-lock.yaml is up-to-date, `pnpm install` skips resolution and
bypasses the minimumReleaseAge check, allowing a freshly-published
version added to the lockfile (e.g. by a developer who disabled the
policy locally) to be installed in CI and other environments without
inspection — defeating the security purpose of the setting.

Re-apply the policy in the lockfile-skip path of `tryFrozenInstall` by
issuing a metadata-only lookup for each npm-registry entry and failing
the install with `ERR_PNPM_MINIMUM_RELEASE_AGE_LOCKFILE_VIOLATION` when
any entry was published after the cutoff. `minimumReleaseAgeExclude`
is respected, non-semver entries (URL/file/git tarballs) are skipped,
and `publishedBy` is forwarded to the resolver so the existing
abbreviated→full metadata escalation handles the npm registry's
default abbreviated responses.

Closes #10438.
…lidation

The previous revision relied on storeController.requestPackage to recover
each pinned version's publish timestamp. That path can take the
peekManifestFromStore fast path (which returns publishedAt: undefined)
or fall through to abbreviated metadata + a 304 cache hit, in either
case the publish time is missing and the check would silently no-op —
re-opening the very bypass the gate is meant to close. Manual end-to-end
testing surfaced this regression after the initial commit landed.

Mirror the approach bun took in oven-sh/bun#30526: build a dedicated
metadata fetcher that goes directly at the registry asking for the full
(un-abbreviated) manifest for every npm-resolved lockfile entry, and
fail closed when the manifest is unreachable or the pinned version is
missing. Decoupling from the resolver pipeline also avoids the
non-strict fallback that would otherwise let an unmatched exact version
be re-picked from the unfiltered manifest.

Also skip the optimisticRepeatInstall fast path in installDeps when
minimumReleaseAge is configured. That fast path declared the install
"up to date" based on lockfile/node_modules state alone and returned
before reaching tryFrozenInstall, so the revalidation gate inside it
never ran.

The lookup is fail-closed: manifest-unavailable, version-not-in-manifest,
or an invalid publish timestamp all surface as violations rather than
silent passes. Violations are aggregated and reported with the three
remediation options (remove from lockfile, lower the bound, add to
minimumReleaseAgeExclude).
Copilot AI review requested due to automatic review settings May 16, 2026 18:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

`as const` on the array literal narrowed it to readonly, but the
`ExecPnpmSyncOpts.omitEnvDefaults` type is a mutable `PnpmEnvDefault[]`.
Move the assertion onto the element so the array itself stays mutable
while still constraining the literal to the union.
@zkochan

zkochan commented May 16, 2026

Copy link
Copy Markdown
Member

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 16, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pnpm/test/install/minimumReleaseAge.ts`:
- Around line 13-16: The IMMATURE_FOR_EVERYTHING constant currently uses ~27
years which will eventually lapse; increase it to a much larger window (e.g.,
use a 1000-year window like 60 * 24 * 365 * 1000) so the “always immature”
cutoff remains future-proof for the is-odd@0.1.2 test; update the
IMMATURE_FOR_EVERYTHING constant accordingly in minimumReleaseAge.ts.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3a41d2e2-fd4d-448f-8731-096426a93df8

📥 Commits

Reviewing files that changed from the base of the PR and between 1f17d35 and 73a0654.

📒 Files selected for processing (5)
  • .changeset/revalidate-minimum-release-age.md
  • installing/deps-installer/src/install/index.ts
  • pnpm/test/install/minimumReleaseAge.ts
  • resolving/default-resolver/src/index.ts
  • resolving/npm-resolver/src/createNpmResolutionVerifier.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • resolving/default-resolver/src/index.ts
  • installing/deps-installer/src/install/index.ts
  • resolving/npm-resolver/src/createNpmResolutionVerifier.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Run benchmark on ubuntu-latest
  • GitHub Check: Compile & Lint
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx,js,jsx}: Use Standard Style with modifications: trailing commas are used, functions are preferred over classes, functions are declared after they are used (hoisting is relied upon).
Functions should have no more than two or three arguments. If a function needs more parameters, use a single options object instead.
Maintain import order: (1) Standard libraries, (2) External dependencies (sorted alphabetically), (3) Relative imports.

Files:

  • pnpm/test/install/minimumReleaseAge.ts
🧠 Learnings (1)
📚 Learning: 2026-05-14T09:04:00.133Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11622
File: resolving/npm-resolver/test/publishedBy.test.ts:350-354
Timestamp: 2026-05-14T09:04:00.133Z
Learning: In the pnpm/pnpm repository, ESLint is the authoritative style linter. Do not raise review findings for missing trailing commas in multiline function calls (e.g., `fs.writeFileSync(...)`) when this repo’s ESLint configuration does not report them and lint passes. Prefer deferring to the ESLint results for this specific trailing-comma rule rather than enforcing it manually in code review.

Applied to files:

  • pnpm/test/install/minimumReleaseAge.ts
🔇 Additional comments (2)
.changeset/revalidate-minimum-release-age.md (1)

1-7: LGTM!

pnpm/test/install/minimumReleaseAge.ts (1)

1-12: LGTM!

Also applies to: 18-97

Comment thread pnpm/test/install/minimumReleaseAge.ts
@zkochan zkochan added the area: supply chain security Issues related to minimumReleaseAge, blockExoticSubdeps, build script safety, and trust policies. label May 16, 2026
@zkochan zkochan enabled auto-merge (squash) May 16, 2026 19:30
@zkochan zkochan disabled auto-merge May 16, 2026 19:37
@zkochan zkochan merged commit 31538bf into main May 16, 2026
14 of 16 checks passed
@zkochan zkochan deleted the fix/revalidate-lockfile-minimum-release-age branch May 16, 2026 19:38
zkochan added a commit that referenced this pull request May 16, 2026
…turns a list

The previous shape — a callable function with an attached
`activeVerifiers` array — was clunky to construct (Object.assign onto an
async function) and gestured at a fan-out the type couldn't express.
Each resolver-side verifier owns exactly one slot, so the array was
always length 1; the combinator's job was to merge sibling slots, which
the install side can do trivially over a plain array.

New shape:

  interface ResolutionVerifier {
    verify: (resolution, ctx) => Promise<ResolutionVerification>
    activeVerifier: ActiveVerifier
  }

The default-resolver companion is now `createResolutionVerifiers`
(plural) and returns `ResolutionVerifier[]` — an empty list when no
policy is active. The install side fans out over the list per lockfile
entry; each verifier handles its own protocol short-circuit inside
`verify`, so dispatch happens naturally without a combinator.

Renames along the chain: `Client.verifyResolution?` →
`Client.resolutionVerifiers: ResolutionVerifier[]`, same for
`StoreControllerHandle`, `CreateTempStoreResult`, and
`StrictInstallOptions`. Empty arrays everywhere `undefined` used to
live.

Breaking, but npm-resolver hasn't been released since the verifier
landed in #11583, so no downstream consumer is affected.
@gsouf

gsouf commented May 17, 2026

Copy link
Copy Markdown

Thanks for merging this and for the cache-poisoning explanation, that fixed the main concern. One thing I'd still like to understand: the perf cost on which you originally pushed back.

Is there a follow-up planned?

Thank you for your time

zkochan added a commit that referenced this pull request May 17, 2026
Closes #11687.

## What

Cache the result of the post-resolution lockfile verification gate (#11583) so repeat installs against an unchanged lockfile skip the per-package registry round trips entirely. Persisted as JSON Lines at `<cacheDir>/lockfile-verified.jsonl`.

The cache layer is policy-neutral. Today there's one verifier (`minimumReleaseAge`); future resolver-side verifiers (jsr trust, attestation, …) plug in by declaring their own `policy` slot and `canTrustPastCheck` comparator — no install-side changes.

## Why

#11583 re-hits the registry on every install for every locked (name, version) pair. On warm/repeat installs where the lockfile hasn't moved, that's a stack of per-package round trips with nothing to show for them. This change makes the steady-state case effectively free without weakening the protection — the gate still runs in full whenever the lockfile changes, any verifier's policy tightens, or no record exists.

## How

### Cache lookup, in order

The cache is **indexed by content hash** so git worktrees with identical lockfile bytes share a cache entry. A secondary path-keyed index drives the same-machine stat shortcut.

1. **`stat()` shortcut** — when a previous record for this exact `lockfilePath` matches today's `size + mtime + inode`, trust the cached hash without reading anything. Zero I/O beyond the stat. Microseconds.
2. **Content lookup** — hash the in-memory lockfile (not the file bytes — we already have the parsed object) and look up by content hash. Catches worktrees (same content, different path) and CI checkouts (same content, reset stat). On hit, append a refreshed path/stat entry so the next install at this path takes the stat shortcut.
3. **Any active verifier rejects the cached `policy`** — run the full gate.
4. **No record** — run the full gate.

The in-memory object is hashed with `hashObject` from `@pnpm/crypto.object-hasher` (streaming, key-order-stable).

### Record shape

```json
{
  "lockfile": {
    "hash": "<sha256 base64>",
    "path": "/abs/path/to/pnpm-lock.yaml",
    "size": 154,
    "mtimeNs": "1736245123000000000",
    "inode": "12345"
  },
  "verifiedAt": "2026-05-17T...",
  "policy": { "minimumReleaseAge": 1440 }
}
```

`policy` is the union of every active verifier's `policy` contribution. Verifiers checking the same logical policy (e.g. `minimumReleaseAge` honored by multiple registries) name it the same and share the slot — no resolver namespacing.

### File semantics

- **Sync fs throughout** — the cache is consulted once before verification fan-out and recorded once after. No concurrent install work to overlap with; keeping the call sites straight-line.
- **JSONL appends are atomic** on POSIX/NTFS, so parallel pnpm processes (monorepo installs, CI matrices sharing a cache) write without coordination. Latest record per `(path, hash)` tuple wins on read.
- **Bounded file** — capped at ~1000 entries; compaction is triggered by a single `stat()` of the cache file (1.5 MiB byte budget) so we never parse the file on the steady-state path. When triggered, the tail is rewritten via tempfile + rename.
- **No record on rejection** — a failing verification deliberately doesn't write a record; the next install must rerun the gate.
- **Single hash per install** — the in-memory hash is computed lazily and reused: `tryLockfileVerificationCache` returns the precomputed stat+hash to `recordVerification` on a miss, and the stat-shortcut hit forwards the cached record's hash unchanged.

## Plumbing

The verifier contract changed alongside the cache to make this composable without install-side knowledge of each policy:

- **`@pnpm/resolving.resolver-base`** — `ResolutionVerifier` is now `{ verify, policy, canTrustPastCheck }` (was a bare function in #11583). Each resolver-side verifier owns its policy snapshot and the comparator that decides whether a cached policy is still trustworthy.
- **`@pnpm/resolving.npm-resolver`** — `createNpmResolutionVerifier` returns the new shape: `policy: { minimumReleaseAge }`, `canTrustPastCheck` reads `minimumReleaseAge` from the merged cached bag.
- **`@pnpm/resolving.default-resolver`** — `createResolutionVerifier` (singular, returning a combined function) → `createResolutionVerifiers` (plural, returning a `ResolutionVerifier[]`). No combinator; each verifier handles its own protocol short-circuit inside `verify`, so dispatch happens naturally at the install side.
- **`@pnpm/installing.client`** — `Client.verifyResolution?` → `Client.resolutionVerifiers: ResolutionVerifier[]`. Same rename propagates through `@pnpm/store.connection-manager`, `@pnpm/testing.temp-store`, and `StrictInstallOptions`.
- **`@pnpm/installing.deps-installer`** — new `verifyLockfileResolutionsCache.ts` (`tryLockfileVerificationCache` + `recordVerification`). `verifyLockfileResolutions` takes the verifier list plus `cacheDir` + `lockfilePath` as flat options; the cache fires when both are present, otherwise the gate runs without memoization. The dedup key for in-flight candidates includes a serialization of `resolution` so two entries sharing a (name, version) but pinned via different protocols don't collapse.

Breaking but safe — `@pnpm/resolving.npm-resolver` hasn't been released since #11583 introduced the verifier abstraction, so no downstream consumer is on the old shape.

## Tests

- **17 unit tests** in `verifyLockfileResolutionsCache.ts`: cache miss/hit, stat shortcut, size mismatch falling through to hash lookup, hash-fallback on reset stat, content change with matching size, stricter/weaker policy, missing-field policy rejection, multi-verifier policy merge (shared field stored once), worktree case (same content, different path), JSONL append semantics, malformed-line tolerance.
- **12 integration tests** in `verifyLockfileResolutions.ts`: dedup of peer/patch-suffix variants, distinct-resolution dedup at the same (name, version), stable violation ordering, the 20-entry cap, multi-verifier fan-out (first failure wins), cache short-circuit on a passing run, no cache write on a rejecting run, empty-verifier-list passthrough.
- **1 e2e test** in `pnpm/test/install/minimumReleaseAge.ts`: bundled CLI plumbing — install once to seed the lockfile, enable `minimumReleaseAge` + `cacheDir`, install again, assert the cache file lands at `<cacheDir>/lockfile-verified.jsonl` with the documented record shape.
- Existing `minimumReleaseAge` (13) and `frozenLockfile` (12) suites still pass.
github-actions Bot pushed a commit to Eyalm321/pnpm that referenced this pull request May 18, 2026
Closes pnpm#10438.

## What

Re-verify every entry in `pnpm-lock.yaml` against the policies the resolver chain was configured with — today: `minimumReleaseAge` in strict mode — right after the lockfile is loaded from disk and before any tarball is fetched. A locked version that fails the policy aborts the install with `ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`; `minimumReleaseAgeExclude` is honored.

## Why

The policy only fires while pnpm is *choosing* a version. Once a version is pinned in the lockfile — e.g. a developer disabled the policy locally and committed a fresh dependency, or a CI cache restored a stale lockfile — every later `pnpm install` (including `--frozen-lockfile` and `pnpm fetch`) installs it without re-checking, which defeats the supply-chain protection the setting is supposed to provide.

The threat model is **a lockfile someone else resolved**, not local resolution: local resolution is already covered by the resolver's own per-version filter. bun fixed the same shape of bug in [oven-sh/bun#30526](oven-sh/bun#30526); this PR is the pnpm side.

## How

The fix introduces a generic `ResolutionVerifier` abstraction in the resolver chain — each resolver factory can ship a sibling verifier factory, exactly the way each resolver ships a `resolve` function. Today there's one verifier (npm); the shape leaves room for future ones (jsr, attestation-based, etc.) without changing the install-side interface.

- **`@pnpm/resolving.resolver-base`** exports the `ResolutionVerifier` / `ResolutionVerification` types — the shared contract.
- **`@pnpm/resolving.npm-resolver`** exports `createNpmResolutionVerifier`. Returns `undefined` when no policy is active, so callers can cheaply decide whether to iterate at all. When active, it inspects each lockfile entry, handles `minimumReleaseAgeExclude`, routes through named-registry prefixes (built-ins like `gh:` merged in), and uses `fetchFullMetadataCached` to fetch full registry metadata — decoupled from the resolver pipeline so neither `peekManifestFromStore` nor abbreviated metadata can hide the publish timestamp.
- **`@pnpm/resolving.default-resolver`** exports `createResolutionVerifier`, a combinator that asks each underlying verifier (today: npm) if it has work and returns `undefined` when none does. Designed so that adding more verifiers later doesn't change the install side.
- **`@pnpm/installing.client`** exposes `verifyResolution` on `Client`, built from the same `fetchFromRegistry` / `getAuthHeader` the resolver chain already uses — **no second fetcher is constructed**.
- **`@pnpm/store.connection-manager`** and **`@pnpm/testing.temp-store`** surface `verifyResolution` alongside the store controller they hand back, so it reaches `mutateModules` through the existing plumbing.
- **`@pnpm/installing.deps-installer`** gains one option on `StrictInstallOptions`: `verifyResolution?: ResolutionVerifier`. `mutateModules` invokes `verifyLockfileResolutions(ctx.wantedLockfile, opts.verifyResolution)` **once**, right after `getContext` returns the on-disk lockfile and before any path branches. When the verifier is `undefined`, the call is a no-op. The iteration is policy-neutral: dedupes by `(name, version)`, applies `pLimit(16)`, sorts violations stably, caps the printed list at 20 with an `…and N more` summary, throws a `PnpmError` carrying the verifier-supplied error code.

The error includes a recovery hint that points at `pnpm clean --lockfile` followed by `pnpm install` — the safe way to throw away a poisoned lockfile and rebuild from fresh resolution.

## Tests

- **9 unit tests** for `verifyLockfileResolutions` against a mock `ResolutionVerifier` — dedup, aggregation, stable ordering, the 20-entry cap, no-op behavior, the verifier-supplied error code surfacing in `PnpmError`.
- **13 integration tests** in `installing/deps-installer/test/install/minimumReleaseAge.ts` via the real `install()` entry — `testDefaults()` wires `verifyResolution` from `createTempStore` → `createClient`, so the npm verifier runs end-to-end at the install boundary. Covers the rejection scenario, `minimumReleaseAgeExclude`, the strict-mode toggle, the existing `minimumReleaseAge` resolver-side suite, and a `pnpm add` scenario where a pre-existing entry would otherwise survive resolution.
- **3 e2e tests** in `pnpm/test/install/minimumReleaseAge.ts` against the bundled CLI: rejection path with the right `ERR_PNPM_*` code and `pnpm clean --lockfile` hint in output, `minimumReleaseAgeExclude` honored, and the strict-off path (which now requires an explicit `minimumReleaseAgeStrict: false` since the config reader auto-enables strict mode when `minimumReleaseAge` is set).
- Existing `frozenLockfile` suite (12 tests) and npm-resolver suite (179 tests) still pass.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
@Lisenish

Copy link
Copy Markdown

@zkochan Happy to see this merged! I'm glad we agreed here and moving this forward, than you! 🙏

I'm sorry for the late reply but I wanted to comment on a couple of things, feel free to ignore if you don't have time for this, it doesn't matter much I'm just curious on your perspective.

Regardless of this change, your CI is clearly misconfigured if your secrets can be exfiltrate at this point.

That's a bit too strong statement, you mean that pnpm install should always happen only in complete isolation of any secrets or only with (ignore-scripts)? In an ideal world in a vacuum probably yeah of course.
But real CIs routinely have install-time access to NPM_TOKEN, GitHub tokens, AWS creds, registry creds, etc. Sandboxing every install script is not the default and breaks many legitimate packages (native builds, etc.). Recent npm worm incidents (shai-hulud, etc.) succeeded precisely because that hardening is rare in practice.
So with all respect but being that dismissive is a bit irresponsible.

Maybe misunderstood something, so please clarify if you have time, I'm really curious about this point.

pnpm audit should be a required check in every PR. If it isn't, you can merge dependencies with vulnerabilities. So why does audit not work?

In my career I've never seen npm/pnpm audit being used as a required check for every PR on CI.
I think it would be a bit too much, since sometimes you can't upgrade dependencies right away (especially for big projects), sometimes the severity is incorrect as well and you don't want to block all PRs on that.
Usually it's a separate process completely and actually I feel like Dependabot/Renovate are much more popular tools for that than audit commands.


@ryo-manba Thanks a lot for working on this :).

@gsouf

gsouf commented May 18, 2026

Copy link
Copy Markdown

@Lisenish Regarding pnpm audit, you can use flags:

  • --audit-level to fail only on, say, critical vulnerabilities,
  • --ignore <vulnerability> to skip a vulnarability if you dont have time to fix it and you flagged it as safe to ship

Regarding the tone of his comment, I dont think you should feel affected. Alas, that's how is open source today. Ton of people pinging you for all and everything, and even the most respectful maintainer will end up sounding dismissive because of the need to deal with volume of issues efficiently.

@Lisenish

Copy link
Copy Markdown

@gsouf Thank you for the reply :)

Regarding pnpm audit, you can use flags

Yeah thanks, the discussion was that pnpm audit already prevents this type of attacks: so if we'd use this parameters it kind defeats the purpose, since it means that we might sneak in vulnerable dependency.
Plus, these types of vulnerabilities detected fast but not immediately (that's why we need minimumReleaseAge parameter).
Moreover it would mean I need to have dependency of all CI jobs with pnpm install on the job with pnpm audit to make sure they're not executed before we confirm there's no vulnerabilities.

Overall I don't think pnpm audit is a good solution for that (while I agree it could work) and happy we agreed to merge PR.
Wether it should be run on CI as required check, or you can use Dependabot/etc - I think it's a matter of preference and could be controversial topic, so the tool itself shouldn't make assumptions about that.

Just incase, it's also not specifically about my or my companies CIs, it has --ignore-scripts setting in install already actually, so the scenario I described above sholdn't be a problem for us (at least while it works for us with this parameter 😄).

BTW, I checked now and it seems it's not required check for pnpm itself also (I think this confirms my point that I never saw it actually being a required one)
image

Regarding the tone of his comment

Yeah I know, I didn't meant the tone actually, I'm not that sensitive haha :). I meant that if you're maintainer of such a big and important tool as pnpm, you can't just dismiss security suggestions with "you just misconfigured your CI!", you must think about security in depth.

Since PR is merged, it;s good already I just curious what is the proper CI setup from the @/zkochan viewpoint, maybe I could learn something.

@melroy89

Copy link
Copy Markdown

is kinda not ideal when migrating from v10 to v11:

✗ Lockfile failed supply-chain policy check (554 entries in 1.7s)
[ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION] 36 lockfile entries failed verification:
  @typescript-eslint/eslint-plugin@8.59.4 was published at 2026-05-18T17:43:41.000Z, within the minimumReleaseAge cutoff (2026-05-18T12:09:22.863Z)
  @typescript-eslint/parser@8.59.4 was published at 2026-05-18T17:43:20.000Z, within the minimumReleaseAge cutoff (2026-05-18T12:09:22.863Z)
  @typescript-eslint/project-servic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: cli/dlx area: supply chain security Issues related to minimumReleaseAge, blockExoticSubdeps, build script safety, and trust policies.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

minimumReleaseAge not enforced when dependency already exists in lockfile

8 participants