feat: add support for trustPolicyExclude (#10168)

close #10164
This commit is contained in:
Ryo Matsukawa
2025-11-11 21:00:20 +09:00
committed by GitHub
parent 075aa993bb
commit 9d3f00b09a
19 changed files with 187 additions and 9 deletions

View File

@@ -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
```

View File

@@ -234,6 +234,7 @@ export interface Config extends OptionsFromRootManifest {
fetchWarnTimeoutMs?: number
fetchMinSpeedKiBps?: number
trustPolicy?: TrustPolicy
trustPolicyExclude?: string[]
}
export interface ConfigWithDeprecatedSettings extends Config {

View File

@@ -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,

View File

@@ -169,6 +169,7 @@ export interface StrictInstallOptions {
minimumReleaseAge?: number
minimumReleaseAgeExclude?: string[]
trustPolicy?: TrustPolicy
trustPolicyExclude?: string[]
}
export type InstallOptions =

View File

@@ -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) {

View File

@@ -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,

View File

@@ -75,6 +75,7 @@ export function rcOptionsTypes (): Record<string, unknown> {
'store-dir',
'strict-peer-dependencies',
'trust-policy',
'trust-policy-exclude',
'unsafe-perm',
'offline',
'only',

View File

@@ -64,6 +64,7 @@ export function rcOptionsTypes (): Record<string, unknown> {
'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 <package-spec>',
},
{
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',

View File

@@ -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,

View File

@@ -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<T> (
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<T> (
}
}
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.

17
pnpm-lock.yaml generated
View File

@@ -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)

View File

@@ -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')
})

View File

@@ -67,6 +67,7 @@
"@pnpm/logger": "catalog:"
},
"devDependencies": {
"@pnpm/config.version-policy": "workspace:*",
"@pnpm/fetch": "workspace:*",
"@pnpm/logger": "workspace:*",
"@pnpm/npm-resolver": "workspace:*",

View File

@@ -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)

View File

@@ -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(

View File

@@ -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()
})
})

View File

@@ -15,6 +15,9 @@
{
"path": "../../config/pick-registry-for-package"
},
{
"path": "../../config/version-policy"
},
{
"path": "../../crypto/hash"
},

View File

@@ -109,6 +109,7 @@ export interface PreferredVersions {
export interface ResolveOptions {
alwaysTryWorkspacePackages?: boolean
trustPolicy?: TrustPolicy
trustPolicyExclude?: PackageVersionPolicy
defaultTag?: string
pickLowestVersion?: boolean
publishedBy?: Date

View File

@@ -138,6 +138,7 @@ export interface RequestPackageOptions {
calcSpecifier?: boolean
pinnedVersion?: PinnedVersion
trustPolicy?: TrustPolicy
trustPolicyExclude?: PackageVersionPolicy
}
export type BundledManifestFunction = () => Promise<BundledManifest | undefined>