diff --git a/.changeset/dlx-inherit-policy-config.md b/.changeset/dlx-inherit-policy-config.md new file mode 100644 index 0000000000..76a26cc985 --- /dev/null +++ b/.changeset/dlx-inherit-policy-config.md @@ -0,0 +1,6 @@ +--- +"@pnpm/config.reader": minor +"pnpm": minor +--- + +`pnpm dlx` and `pnpm create` now respect security and trust policy settings (`minimumReleaseAge`, `minimumReleaseAgeExclude`, `minimumReleaseAgeStrict`, `trustPolicy`, `trustPolicyExclude`, `trustPolicyIgnoreAfter`) from project-level configuration [#11183](https://github.com/pnpm/pnpm/issues/11183). diff --git a/config/reader/src/auth.test.ts b/config/reader/src/auth.test.ts index fb30bfaec8..b53534661e 100644 --- a/config/reader/src/auth.test.ts +++ b/config/reader/src/auth.test.ts @@ -1,4 +1,4 @@ -import { inheritAuthConfig } from './auth.js' +import { inheritAuthConfig, inheritDlxConfig } from './auth.js' import type { InheritableConfigPair } from './inheritPickedConfig.js' test('inheritAuthConfig copies only auth keys from source to target', () => { @@ -38,3 +38,62 @@ test('inheritAuthConfig copies only auth keys from source to target', () => { }, }) }) + +test('inheritDlxConfig copies auth and security policy keys from source to target', () => { + const target: InheritableConfigPair = { + config: { + bin: 'foo', + cacheDir: '/path/to/cache/dir', + registry: 'https://npmjs.com/registry/', + shamefullyHoist: true, + authConfig: { + registry: 'https://npmjs.com/registry/', + }, + } as any, // eslint-disable-line + } + + inheritDlxConfig(target, { + config: { + bin: 'bar', + cacheDir: '/path/to/another/cache/dir', + storeDir: '/path/to/custom/store/dir', + registry: 'https://example.com/local-registry/', + shamefullyHoist: false, + minimumReleaseAge: 1440, + minimumReleaseAgeExclude: ['trusted-pkg'], + minimumReleaseAgeStrict: true, + trustPolicy: 'no-downgrade', + trustPolicyExclude: ['legacy-pkg'], + trustPolicyIgnoreAfter: 525600, + authConfig: { + registry: 'https://example.com/local-registry/', + '//example.com/local-registry/:_authToken': 'SECRET_TOKEN', + 'minimum-release-age': '1440', + }, + } as any, // eslint-disable-line + }) + + // Auth keys are inherited + expect(target.config.registry).toBe('https://example.com/local-registry/') + + // Security/trust policy keys are inherited + expect(target.config.minimumReleaseAge).toBe(1440) + expect(target.config.minimumReleaseAgeExclude).toEqual(['trusted-pkg']) + expect(target.config.minimumReleaseAgeStrict).toBe(true) + expect(target.config.trustPolicy).toBe('no-downgrade') + expect(target.config.trustPolicyExclude).toEqual(['legacy-pkg']) + expect(target.config.trustPolicyIgnoreAfter).toBe(525600) + + // Project-structural keys are NOT inherited + expect(target.config.bin).toBe('foo') + expect(target.config.cacheDir).toBe('/path/to/cache/dir') + expect(target.config.shamefullyHoist).toBe(true) + expect(target.config.storeDir).toBeUndefined() + + // Raw auth keys are inherited, raw policy keys are inherited + expect(target.config.authConfig).toMatchObject({ + registry: 'https://example.com/local-registry/', + '//example.com/local-registry/:_authToken': 'SECRET_TOKEN', + 'minimum-release-age': '1440', + }) +}) diff --git a/config/reader/src/auth.ts b/config/reader/src/auth.ts index 79410f65e1..0cb7319444 100644 --- a/config/reader/src/auth.ts +++ b/config/reader/src/auth.ts @@ -45,6 +45,56 @@ const AUTH_CFG_KEYS = [ 'registries', ] satisfies Array +/** + * Security and trust policy config keys (camelCase, from Config type). + * + * ## Principle + * + * `pnpm dlx` runs packages in isolation from the current project. It must not + * read project-structural settings (hoisting, linking, workspace layout, etc.) + * from local config. However, two categories of local settings DO apply: + * + * 1. **Registry & auth** — needed to reach the same package sources + * (registries, tokens, certificates, proxies). + * 2. **Security & trust policy** — these reflect the user's or organization's + * security posture and must apply regardless of how a package is installed. + * A setting that answers "what am I allowed to download?" belongs here. + * + * Everything else — settings that answer "how should I arrange what I + * downloaded?" — is intentionally excluded. + * + * ## Rules + * + * | Category | Inherited by dlx? | Examples | + * |--------------------------------|--------------------|--------------------------------------------------| + * | Registry & auth | Yes | registry, _authToken, ca, proxy | + * | Security & trust policy | Yes | minimumReleaseAge, trustPolicy | + * | Installation structure | No | shamefully-hoist, node-linker, hoist-pattern | + * | Workspace settings | No | link-workspace-packages, shared-workspace-lockfile| + * | Resolution strategy | No | resolution-mode, dedupe-peers | + */ +const POLICY_CFG_KEYS = [ + 'minimumReleaseAge', + 'minimumReleaseAgeExclude', + 'minimumReleaseAgeStrict', + 'trustPolicy', + 'trustPolicyExclude', + 'trustPolicyIgnoreAfter', +] satisfies Array + +/** + * Raw (kebab-case) config keys for security and trust policy settings. + * These are the keys as they appear in .npmrc or pnpm-workspace.yaml. + */ +const RAW_POLICY_CFG_KEYS = [ + 'minimum-release-age', + 'minimum-release-age-exclude', + 'minimum-release-age-strict', + 'trust-policy', + 'trust-policy-exclude', + 'trust-policy-ignore-after', +] + const NPM_AUTH_SETTINGS = [ ...RAW_AUTH_CFG_KEYS, '_auth', @@ -65,6 +115,14 @@ function isAuthCfgKey (cfgKey: keyof Config): cfgKey is typeof AUTH_CFG_KEYS[num return (AUTH_CFG_KEYS as Array).includes(cfgKey) } +function isPolicyCfgKey (cfgKey: keyof Config): cfgKey is typeof POLICY_CFG_KEYS[number] { + return (POLICY_CFG_KEYS as Array).includes(cfgKey) +} + +function isRawPolicyCfgKey (rawCfgKey: string): boolean { + return (RAW_POLICY_CFG_KEYS as string[]).includes(rawCfgKey) +} + function pickRawAuthConfig> (rawLocalCfg: RawLocalCfg): Partial { const result: Partial = {} for (const key in rawLocalCfg) { @@ -85,10 +143,42 @@ function pickAuthConfig (localCfg: Partial): Partial { return result as Partial } +function pickRawDlxConfig> (rawLocalCfg: RawLocalCfg): Partial { + const result: Partial = {} + for (const key in rawLocalCfg) { + if (isRawAuthCfgKey(key) || isRawPolicyCfgKey(key)) { + result[key] = rawLocalCfg[key] + } + } + return result +} + +function pickDlxConfig (localCfg: Partial): Partial { + const result: Record = {} + for (const key in localCfg) { + if (isAuthCfgKey(key as keyof Config) || isPolicyCfgKey(key as keyof Config)) { + result[key] = localCfg[key as keyof Config] + } + } + return result as Partial +} + export function inheritAuthConfig (target: InheritableConfigPair, src: InheritableConfigPair): void { inheritPickedConfig(target, src, pickAuthConfig, pickRawAuthConfig) } +/** + * Inherits both auth/registry settings and security/trust policy settings + * from a local config source into the target config. + * + * Used by `pnpm dlx` and `pnpm create` so that these commands respect + * the local project's registry authentication and security policies + * while ignoring project-structural settings. + */ +export function inheritDlxConfig (target: InheritableConfigPair, src: InheritableConfigPair): void { + inheritPickedConfig(target, src, pickDlxConfig, pickRawDlxConfig) +} + /** * Whether the config key would be read from an INI config file. */ diff --git a/config/reader/src/index.ts b/config/reader/src/index.ts index 60f2b3fb56..b21ccfef95 100644 --- a/config/reader/src/index.ts +++ b/config/reader/src/index.ts @@ -22,7 +22,7 @@ import { omit } from 'ramda' import { realpathMissing } from 'realpath-missing' import semver from 'semver' -import { inheritAuthConfig, pickIniConfig } from './auth.js' +import { inheritDlxConfig, pickIniConfig } from './auth.js' import { checkGlobalBinDir } from './checkGlobalBinDir.js' import { getDefaultWorkspaceConcurrency, getWorkspaceConcurrency } from './concurrency.js' import type { @@ -88,22 +88,22 @@ export async function getConfig (opts: { } workspaceDir?: string | undefined env?: Record - ignoreNonAuthSettingsFromLocal?: boolean + onlyInheritDlxSettingsFromLocal?: boolean ignoreLocalSettings?: boolean }): Promise<{ config: Config, context: ConfigContext, warnings: string[] }> { - if (opts.ignoreNonAuthSettingsFromLocal) { - const { ignoreNonAuthSettingsFromLocal: _, ...authOpts } = opts - const globalCfgOpts: typeof authOpts = { - ...authOpts, + if (opts.onlyInheritDlxSettingsFromLocal) { + const { onlyInheritDlxSettingsFromLocal: _, ...localOpts } = opts + const globalCfgOpts: typeof localOpts = { + ...localOpts, ignoreLocalSettings: true, cliOptions: { - ...authOpts.cliOptions, + ...localOpts.cliOptions, dir: os.homedir(), }, } - const [final, authSrc] = await Promise.all([getConfig(globalCfgOpts), getConfig(authOpts)]) - inheritAuthConfig(final, authSrc) - final.warnings.push(...authSrc.warnings) + const [final, localSrc] = await Promise.all([getConfig(globalCfgOpts), getConfig(localOpts)]) + inheritDlxConfig(final, localSrc) + final.warnings.push(...localSrc.warnings) return final } diff --git a/pnpm/src/getConfig.ts b/pnpm/src/getConfig.ts index fa1ff5120f..f9fc8c7735 100644 --- a/pnpm/src/getConfig.ts +++ b/pnpm/src/getConfig.ts @@ -16,7 +16,7 @@ export async function getConfig ( excludeReporter: boolean globalDirShouldAllowWrite?: boolean workspaceDir: string | undefined - ignoreNonAuthSettingsFromLocal?: boolean + onlyInheritDlxSettingsFromLocal?: boolean } ): Promise<{ config: Config, context: ConfigContext }> { const { config, context, warnings } = await _getConfig({ @@ -24,7 +24,7 @@ export async function getConfig ( globalDirShouldAllowWrite: opts.globalDirShouldAllowWrite, packageManager, workspaceDir: opts.workspaceDir, - ignoreNonAuthSettingsFromLocal: opts.ignoreNonAuthSettingsFromLocal, + onlyInheritDlxSettingsFromLocal: opts.onlyInheritDlxSettingsFromLocal, }) context.cliOptions = cliOptions applyDerivedConfig(config) diff --git a/pnpm/src/main.ts b/pnpm/src/main.ts index ff07427aa4..40b70e1212 100644 --- a/pnpm/src/main.ts +++ b/pnpm/src/main.ts @@ -100,7 +100,7 @@ export async function main (inputArgv: string[]): Promise { excludeReporter: false, globalDirShouldAllowWrite, workspaceDir, - ignoreNonAuthSettingsFromLocal: isDlxOrCreateCommand, + onlyInheritDlxSettingsFromLocal: isDlxOrCreateCommand, }) as { config: typeof config, context: ConfigContext }) if (!isExecutedByCorepack() && cmd !== 'setup' && context.wantedPackageManager != null) { const pm = context.wantedPackageManager