diff --git a/.changeset/tiny-rivers-crash.md b/.changeset/tiny-rivers-crash.md new file mode 100644 index 0000000000..d2b2ccb46f --- /dev/null +++ b/.changeset/tiny-rivers-crash.md @@ -0,0 +1,22 @@ +--- +"@pnpm/plugin-commands-installation": minor +"@pnpm/resolve-dependencies": minor +"@pnpm/package-requester": minor +"@pnpm/store-controller-types": minor +"@pnpm/resolver-base": minor +"@pnpm/npm-resolver": minor +"@pnpm/core": minor +"@pnpm/config": minor +"pnpm": minor +--- + +Added support for `trustPolicyExclude` [#10164](https://github.com/pnpm/pnpm/issues/10164). + +You can now list one or more specific packages or versions that pnpm should allow to install, even if those packages don't satisfy the trust policy requirement. For example: + +```yaml +trustPolicy: no-downgrade +trustPolicyExclude: + - chokidar@4.0.3 + - webpack@4.47.0 || 5.102.1 +``` diff --git a/config/config/src/Config.ts b/config/config/src/Config.ts index f975c283df..d38a0e39e9 100644 --- a/config/config/src/Config.ts +++ b/config/config/src/Config.ts @@ -234,6 +234,7 @@ export interface Config extends OptionsFromRootManifest { fetchWarnTimeoutMs?: number fetchMinSpeedKiBps?: number trustPolicy?: TrustPolicy + trustPolicyExclude?: string[] } export interface ConfigWithDeprecatedSettings extends Config { diff --git a/config/config/src/types.ts b/config/config/src/types.ts index df84071b0e..e9078325cd 100644 --- a/config/config/src/types.ts +++ b/config/config/src/types.ts @@ -115,6 +115,7 @@ export const pnpmTypes = { 'strict-store-pkg-content-check': Boolean, 'strict-peer-dependencies': Boolean, 'trust-policy': ['off', 'no-downgrade'] satisfies TrustPolicy[], + 'trust-policy-exclude': [String, Array], 'use-beta-cli': Boolean, 'use-node-version': String, 'use-running-store-server': Boolean, diff --git a/pkg-manager/core/src/install/extendInstallOptions.ts b/pkg-manager/core/src/install/extendInstallOptions.ts index 3e909563dc..b61d556cab 100644 --- a/pkg-manager/core/src/install/extendInstallOptions.ts +++ b/pkg-manager/core/src/install/extendInstallOptions.ts @@ -169,6 +169,7 @@ export interface StrictInstallOptions { minimumReleaseAge?: number minimumReleaseAgeExclude?: string[] trustPolicy?: TrustPolicy + trustPolicyExclude?: string[] } export type InstallOptions = diff --git a/pkg-manager/core/src/install/index.ts b/pkg-manager/core/src/install/index.ts index 59746fe174..c415fb905d 100644 --- a/pkg-manager/core/src/install/index.ts +++ b/pkg-manager/core/src/install/index.ts @@ -1178,6 +1178,7 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => { minimumReleaseAge: opts.minimumReleaseAge, minimumReleaseAgeExclude: opts.minimumReleaseAgeExclude, trustPolicy: opts.trustPolicy, + trustPolicyExclude: opts.trustPolicyExclude, } ) if (!opts.include.optionalDependencies || !opts.include.devDependencies || !opts.include.dependencies) { diff --git a/pkg-manager/package-requester/src/packageRequester.ts b/pkg-manager/package-requester/src/packageRequester.ts index 9284da53d4..b663fdce39 100644 --- a/pkg-manager/package-requester/src/packageRequester.ts +++ b/pkg-manager/package-requester/src/packageRequester.ts @@ -210,6 +210,7 @@ async function resolveAndFetch ( alwaysTryWorkspacePackages: options.alwaysTryWorkspacePackages, defaultTag: options.defaultTag, trustPolicy: options.trustPolicy, + trustPolicyExclude: options.trustPolicyExclude, publishedBy: options.publishedBy, publishedByExclude: options.publishedByExclude, pickLowestVersion: options.pickLowestVersion, diff --git a/pkg-manager/plugin-commands-installation/src/add.ts b/pkg-manager/plugin-commands-installation/src/add.ts index 9dc7ecaf2c..efb57adf7d 100644 --- a/pkg-manager/plugin-commands-installation/src/add.ts +++ b/pkg-manager/plugin-commands-installation/src/add.ts @@ -75,6 +75,7 @@ export function rcOptionsTypes (): Record { 'store-dir', 'strict-peer-dependencies', 'trust-policy', + 'trust-policy-exclude', 'unsafe-perm', 'offline', 'only', diff --git a/pkg-manager/plugin-commands-installation/src/install.ts b/pkg-manager/plugin-commands-installation/src/install.ts index e14b1a6638..7032bec84f 100644 --- a/pkg-manager/plugin-commands-installation/src/install.ts +++ b/pkg-manager/plugin-commands-installation/src/install.ts @@ -64,6 +64,7 @@ export function rcOptionsTypes (): Record { 'store-dir', 'strict-peer-dependencies', 'trust-policy', + 'trust-policy-exclude', 'offline', 'only', 'optional', @@ -208,6 +209,10 @@ by any dependencies, so it is an emulation of a flat node_modules', description: "Fail when a package's trust level is downgraded (e.g., from a trusted publisher to provenance only or no trust evidence)", name: '--trust-policy no-downgrade', }, + { + description: 'Exclude specific packages from trust policy checks', + name: '--trust-policy-exclude ', + }, { description: 'Starts a store server in the background. The store server will keep running after installation is done. To stop the store server, run `pnpm server stop`', name: '--use-store-server', diff --git a/pkg-manager/resolve-dependencies/src/resolveDependencies.ts b/pkg-manager/resolve-dependencies/src/resolveDependencies.ts index 937ac79e27..33d8825a6f 100644 --- a/pkg-manager/resolve-dependencies/src/resolveDependencies.ts +++ b/pkg-manager/resolve-dependencies/src/resolveDependencies.ts @@ -182,6 +182,7 @@ export interface ResolutionContext { maximumPublishedBy?: Date publishedByExclude?: PackageVersionPolicy trustPolicy?: TrustPolicy + trustPolicyExclude?: PackageVersionPolicy } export interface MissingPeerInfo { @@ -1337,6 +1338,7 @@ async function resolveDependency ( : options.parentPkg.rootDir, skipFetch: ctx.dryRun, trustPolicy: ctx.trustPolicy, + trustPolicyExclude: ctx.trustPolicyExclude, update: options.update, workspacePackages: ctx.workspacePackages, supportedArchitectures: options.supportedArchitectures, diff --git a/pkg-manager/resolve-dependencies/src/resolveDependencyTree.ts b/pkg-manager/resolve-dependencies/src/resolveDependencyTree.ts index 43a6da991c..f39cf77604 100644 --- a/pkg-manager/resolve-dependencies/src/resolveDependencyTree.ts +++ b/pkg-manager/resolve-dependencies/src/resolveDependencyTree.ts @@ -139,6 +139,7 @@ export interface ResolveDependenciesOptions { minimumReleaseAge?: number minimumReleaseAgeExclude?: string[] trustPolicy?: TrustPolicy + trustPolicyExclude?: string[] } export interface ResolveDependencyTreeResult { @@ -202,6 +203,7 @@ export async function resolveDependencyTree ( maximumPublishedBy: opts.minimumReleaseAge ? new Date(Date.now() - opts.minimumReleaseAge * 60 * 1000) : undefined, publishedByExclude: opts.minimumReleaseAgeExclude ? createPublishedByExclude(opts.minimumReleaseAgeExclude) : undefined, trustPolicy: opts.trustPolicy, + trustPolicyExclude: opts.trustPolicyExclude ? createTrustPolicyExclude(opts.trustPolicyExclude) : undefined, } function createPublishedByExclude (patterns: string[]): PackageVersionPolicy { @@ -213,6 +215,15 @@ export async function resolveDependencyTree ( } } + function createTrustPolicyExclude (patterns: string[]): PackageVersionPolicy { + try { + return createPackageVersionPolicy(patterns) + } catch (err) { + if (!err || typeof err !== 'object' || !('message' in err)) throw err + throw new PnpmError('INVALID_TRUST_POLICY_EXCLUDE', `Invalid value in trustPolicyExclude: ${err.message as string}`) + } + } + const resolveArgs: ImporterToResolve[] = importers.map((importer) => { const projectSnapshot = opts.wantedLockfile.importers[importer.id] // This may be optimized. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af721db36d..f7a2c667c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7427,6 +7427,9 @@ importers: specifier: 'catalog:' version: 3.0.0 devDependencies: + '@pnpm/config.version-policy': + specifier: workspace:* + version: link:../../config/version-policy '@pnpm/fetch': specifier: workspace:* version: link:../../network/fetch @@ -18253,7 +18256,7 @@ snapshots: '@pnpm/fs.packlist': 2.0.0 '@pnpm/logger': 1001.0.0 '@pnpm/prepare-package': 1000.0.16(@pnpm/logger@1001.0.0)(typanion@3.14.0) - '@pnpm/worker': 1000.1.7(@pnpm/logger@packages+logger)(@types/node@22.15.30) + '@pnpm/worker': 1000.1.7(@pnpm/logger@1001.0.0)(@types/node@22.15.30) '@zkochan/rimraf': 3.0.2 execa: safe-execa@0.1.2 transitivePeerDependencies: @@ -18388,7 +18391,7 @@ snapshots: '@pnpm/find-workspace-dir': 1000.1.0 '@pnpm/logger': 1001.0.0 '@pnpm/types': 1000.6.0 - '@pnpm/worker': 1000.1.7(@pnpm/logger@packages+logger)(@types/node@22.15.30) + '@pnpm/worker': 1000.1.7(@pnpm/logger@1001.0.0)(@types/node@22.15.30) '@pnpm/workspace.find-packages': 1000.0.25(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.1.7(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0) '@pnpm/workspace.read-manifest': 1000.1.5 load-json-file: 7.0.1 @@ -18588,7 +18591,7 @@ snapshots: '@pnpm/store-controller-types': 1003.0.2 '@pnpm/store.cafs': 1000.0.13 '@pnpm/types': 1000.6.0 - '@pnpm/worker': 1000.1.7(@pnpm/logger@packages+logger)(@types/node@22.15.30) + '@pnpm/worker': 1000.1.7(@pnpm/logger@1001.0.0)(@types/node@22.15.30) p-defer: 3.0.0 p-limit: 3.1.0 p-queue: 6.6.2 @@ -18607,7 +18610,7 @@ snapshots: '@pnpm/store-controller-types': 1003.0.2 '@pnpm/store.cafs': 1000.0.13 '@pnpm/types': 1000.6.0 - '@pnpm/worker': 1000.1.7(@pnpm/logger@packages+logger)(@types/node@22.15.30) + '@pnpm/worker': 1000.1.7(@pnpm/logger@1001.0.0)(@types/node@22.15.30) '@zkochan/rimraf': 3.0.2 load-json-file: 6.2.0 ramda: '@pnpm/ramda@0.28.1' @@ -18886,7 +18889,7 @@ snapshots: '@pnpm/graceful-fs': 1000.0.0 '@pnpm/logger': 1001.0.0 '@pnpm/prepare-package': 1000.0.16(@pnpm/logger@1001.0.0)(typanion@3.14.0) - '@pnpm/worker': 1000.1.7(@pnpm/logger@packages+logger)(@types/node@22.15.30) + '@pnpm/worker': 1000.1.7(@pnpm/logger@1001.0.0)(@types/node@22.15.30) '@zkochan/retry': 0.2.0 lodash.throttle: 4.1.1 p-map-values: 1.0.0 @@ -18925,7 +18928,7 @@ snapshots: dependencies: isexe: 2.0.0 - '@pnpm/worker@1000.1.7(@pnpm/logger@packages+logger)(@types/node@22.15.30)': + '@pnpm/worker@1000.1.7(@pnpm/logger@1001.0.0)(@types/node@22.15.30)': dependencies: '@pnpm/cafs-types': 1000.0.0 '@pnpm/create-cafs-store': 1000.0.14(@pnpm/logger@1001.0.0) @@ -18934,7 +18937,7 @@ snapshots: '@pnpm/exec.pkg-requires-build': 1000.0.8 '@pnpm/fs.hard-link-dir': 1000.0.1(@pnpm/logger@1001.0.0) '@pnpm/graceful-fs': 1000.0.0 - '@pnpm/logger': link:packages/logger + '@pnpm/logger': 1001.0.0 '@pnpm/store.cafs': 1000.0.13 '@pnpm/symlink-dependency': 1000.0.9(@pnpm/logger@1001.0.0) '@rushstack/worker-pool': 0.4.9(@types/node@22.15.30) diff --git a/pnpm/test/install/misc.ts b/pnpm/test/install/misc.ts index d3ffa967a8..47e6b199eb 100644 --- a/pnpm/test/install/misc.ts +++ b/pnpm/test/install/misc.ts @@ -527,3 +527,27 @@ test('install does not fail when the trust evidence of a package is downgraded b expect(result.status).toBe(0) project.has('@pnpm/e2e.test-provenance') }) + +test('install does not fail when the trust evidence of a package is downgraded but it is in trust-policy-exclude', async () => { + const project = prepare() + const result = execPnpmSync([ + 'add', + '@pnpm/e2e.test-provenance@0.0.5', + '--trust-policy=no-downgrade', + '--trust-policy-exclude=@pnpm/e2e.test-provenance@0.0.5', + ]) + expect(result.status).toBe(0) + project.has('@pnpm/e2e.test-provenance') +}) + +test('install does not fail when the trust evidence of a package is downgraded but the package name is in trust-policy-exclude', async () => { + const project = prepare() + const result = execPnpmSync([ + 'add', + '@pnpm/e2e.test-provenance@0.0.5', + '--trust-policy=no-downgrade', + '--trust-policy-exclude=@pnpm/e2e.test-provenance', + ]) + expect(result.status).toBe(0) + project.has('@pnpm/e2e.test-provenance') +}) diff --git a/resolving/npm-resolver/package.json b/resolving/npm-resolver/package.json index b73b086008..fcc4f8cd59 100644 --- a/resolving/npm-resolver/package.json +++ b/resolving/npm-resolver/package.json @@ -67,6 +67,7 @@ "@pnpm/logger": "catalog:" }, "devDependencies": { + "@pnpm/config.version-policy": "workspace:*", "@pnpm/fetch": "workspace:*", "@pnpm/logger": "workspace:*", "@pnpm/npm-resolver": "workspace:*", diff --git a/resolving/npm-resolver/src/index.ts b/resolving/npm-resolver/src/index.ts index 4232c62049..9809119cfa 100644 --- a/resolving/npm-resolver/src/index.ts +++ b/resolving/npm-resolver/src/index.ts @@ -188,6 +188,7 @@ export type ResolveFromNpmOptions = { publishedByExclude?: PackageVersionPolicy pickLowestVersion?: boolean trustPolicy?: TrustPolicy + trustPolicyExclude?: PackageVersionPolicy dryRun?: boolean lockfileDir?: string preferredVersions?: PreferredVersions @@ -308,7 +309,7 @@ async function resolveNpm ( throw new NoMatchingVersionError({ wantedDependency, packageMeta: meta, registry }) } else if (opts.trustPolicy === 'no-downgrade') { assertMetaHasTime(meta) - failIfTrustDowngraded(meta, pickedPackage.version) + failIfTrustDowngraded(meta, pickedPackage.version, opts.trustPolicyExclude) } const workspacePkgsMatchingName = workspacePackages?.get(pickedPackage.name) diff --git a/resolving/npm-resolver/src/trustChecks.ts b/resolving/npm-resolver/src/trustChecks.ts index a4df1d1de1..3b9fdaeb5f 100644 --- a/resolving/npm-resolver/src/trustChecks.ts +++ b/resolving/npm-resolver/src/trustChecks.ts @@ -1,5 +1,6 @@ import { PnpmError } from '@pnpm/error' import { type PackageInRegistry, type PackageMetaWithTime } from '@pnpm/registry.types' +import { type PackageVersionPolicy } from '@pnpm/types' type TrustEvidence = 'provenance' | 'trustedPublisher' @@ -10,8 +11,19 @@ const TRUST_RANK = { export function failIfTrustDowngraded ( meta: PackageMetaWithTime, - version: string + version: string, + trustPolicyExclude?: PackageVersionPolicy ): void { + if (trustPolicyExclude) { + const excludeResult = trustPolicyExclude(meta.name) + if (excludeResult === true) { + return + } + if (Array.isArray(excludeResult) && excludeResult.includes(version)) { + return + } + } + const versionPublishedAt = meta.time[version] if (!versionPublishedAt) { throw new PnpmError( diff --git a/resolving/npm-resolver/test/trustChecks.test.ts b/resolving/npm-resolver/test/trustChecks.test.ts index 2b35ba2aaa..4bd62817d1 100644 --- a/resolving/npm-resolver/test/trustChecks.test.ts +++ b/resolving/npm-resolver/test/trustChecks.test.ts @@ -1,4 +1,5 @@ import { type PackageInRegistry, type PackageMetaWithTime } from '@pnpm/registry.types' +import { createPackageVersionPolicy } from '@pnpm/config.version-policy' import { getTrustEvidence, failIfTrustDowngraded } from '../src/trustChecks.js' describe('getTrustEvidence', () => { @@ -405,3 +406,88 @@ describe('failIfTrustDowngraded', () => { }).toThrow('Missing time') }) }) + +describe('failIfTrustDowngraded with trustPolicyExclude', () => { + test('allows downgrade when package@version is in exclude list', () => { + const meta: PackageMetaWithTime = { + name: 'foo', + 'dist-tags': { latest: '3.0.0' }, + versions: { + '2.0.0': { + name: 'foo', + version: '2.0.0', + dist: { + shasum: 'def456', + tarball: 'https://registry.example.com/foo/-/foo-2.0.0.tgz', + attestations: { + provenance: { + predicateType: 'https://slsa.dev/provenance/v1', + }, + }, + }, + }, + '3.0.0': { + name: 'foo', + version: '3.0.0', + dist: { + shasum: 'ghi789', + tarball: 'https://registry.example.com/foo/-/foo-3.0.0.tgz', + }, + }, + }, + time: { + '2.0.0': '2025-02-01T00:00:00.000Z', + '3.0.0': '2025-03-01T00:00:00.000Z', + }, + } + + expect(() => { + failIfTrustDowngraded(meta, '3.0.0', createPackageVersionPolicy(['foo@3.0.0'])) + }).not.toThrow() + + expect(() => { + failIfTrustDowngraded(meta, '3.0.0') + }).toThrow('High-risk trust downgrade') + }) + + test('allows downgrade when package name is in exclude list (all versions)', () => { + const meta: PackageMetaWithTime = { + name: 'bar', + 'dist-tags': { latest: '3.0.0' }, + versions: { + '2.0.0': { + name: 'bar', + version: '2.0.0', + _npmUser: { + name: 'test-publisher', + email: 'publisher@example.com', + trustedPublisher: { + id: 'test-provider', + oidcConfigId: 'oidc:test-config-123', + }, + }, + dist: { + shasum: 'def456', + tarball: 'https://registry.example.com/bar/-/bar-2.0.0.tgz', + }, + }, + '3.0.0': { + name: 'bar', + version: '3.0.0', + dist: { + shasum: 'ghi789', + tarball: 'https://registry.example.com/bar/-/bar-3.0.0.tgz', + }, + }, + }, + time: { + '2.0.0': '2025-02-01T00:00:00.000Z', + '3.0.0': '2025-03-01T00:00:00.000Z', + }, + } + + expect(() => { + failIfTrustDowngraded(meta, '3.0.0', createPackageVersionPolicy(['bar'])) + }).not.toThrow() + }) +}) diff --git a/resolving/npm-resolver/tsconfig.json b/resolving/npm-resolver/tsconfig.json index 0f7a43d6ef..47abf7d82a 100644 --- a/resolving/npm-resolver/tsconfig.json +++ b/resolving/npm-resolver/tsconfig.json @@ -15,6 +15,9 @@ { "path": "../../config/pick-registry-for-package" }, + { + "path": "../../config/version-policy" + }, { "path": "../../crypto/hash" }, diff --git a/resolving/resolver-base/src/index.ts b/resolving/resolver-base/src/index.ts index aaff8eff28..7a59e8eb2c 100644 --- a/resolving/resolver-base/src/index.ts +++ b/resolving/resolver-base/src/index.ts @@ -109,6 +109,7 @@ export interface PreferredVersions { export interface ResolveOptions { alwaysTryWorkspacePackages?: boolean trustPolicy?: TrustPolicy + trustPolicyExclude?: PackageVersionPolicy defaultTag?: string pickLowestVersion?: boolean publishedBy?: Date diff --git a/store/store-controller-types/src/index.ts b/store/store-controller-types/src/index.ts index 346d06ca57..9acbe73e48 100644 --- a/store/store-controller-types/src/index.ts +++ b/store/store-controller-types/src/index.ts @@ -138,6 +138,7 @@ export interface RequestPackageOptions { calcSpecifier?: boolean pinnedVersion?: PinnedVersion trustPolicy?: TrustPolicy + trustPolicyExclude?: PackageVersionPolicy } export type BundledManifestFunction = () => Promise