Skip to content

fix: block untrusted request destination env expansion#12291

Merged
zkochan merged 5 commits into
mainfrom
vuln-x77r
Jun 9, 2026
Merged

fix: block untrusted request destination env expansion#12291
zkochan merged 5 commits into
mainfrom
vuln-x77r

Conversation

@zkochan

@zkochan zkochan commented Jun 9, 2026

Copy link
Copy Markdown
Member

Summary

Fixes CAND-PNPM-122 / GHSA-3qhv-2rgh-x77r by making environment expansion trust-aware for registry/auth config and request destinations.

  • Stops project .npmrc from expanding ${...} placeholders in registry/proxy request destinations, URL-scoped keys, and registry credential values.
  • Stops repository-controlled pnpm-workspace.yaml from expanding ${...} placeholders in request destinations: registry, registries, namedRegistries, and pnprServer.
  • Preserves env expansion for trusted user/global/auth.ini/CLI/global config/env config so existing token, registry, and pnpr server setup flows continue to work.
  • Ports the same trust boundary to pacquet for dependency-management commands.

Verification

  • ./node_modules/.bin/tsgo --build config/reader/tsconfig.json
  • NODE_OPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../../node_modules/.bin/jest test/getOptionsFromRootManifest.test.ts --runInBand
  • focused config/reader/test/index.ts Jest cases for project .npmrc, trusted user/global config, and workspace request-destination env placeholders
  • ./node_modules/.bin/eslint config/reader/src/loadNpmrcFiles.ts config/reader/src/getOptionsFromRootManifest.ts config/reader/src/index.ts config/reader/test/index.ts config/reader/test/getOptionsFromRootManifest.test.ts
  • cargo fmt --all -- --check
  • focused cargo test --manifest-path pacquet/crates/config/Cargo.toml ... --lib cases for project/trusted .npmrc, trusted global/env config, and workspace YAML request-destination env placeholders
  • git diff --check
  • pre-push hook completed successfully while pushing vuln-x77r at 9ec847d1b5

Written by an agent (Codex, GPT-5).

@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Project/workspace-controlled registry/proxy destination URLs and registry credential values with ${...} placeholders are ignored (dropped + warned). Trusted/global/user/CLI sources continue to expand placeholders. Changes span JS config/reader, Rust npmrc parsing, workspace YAML substitution, tests, CLI integration tests, and a changeset.

Changes

Registry Environment Variable Placeholder Handling

Layer / File(s) Summary
Changeset metadata
.changeset/sharp-registry-env-placeholders.md
Updates release note and marks package bumps for @pnpm/config.reader and pnpm as minor; documents placeholder-behavior change.
pnpm settings placeholder filtering
config/reader/src/getOptionsFromRootManifest.ts
Adds options plumbing and replaceEnvInSettings behaviour to omit registry/request-destination entries containing ${...} unless expansion is explicitly enabled.
Load workspace .npmrc with filtering
config/reader/src/loadNpmrcFiles.ts
Adds ReadAndFilterNpmrcOptions, disables project/workspace auth & request-destination expansion, and warns/omits placeholder-bearing keys/values when reading workspace .npmrc.
pnpm reader config assembly
config/reader/src/index.ts
Passes expandRequestDestinationEnv: true for workspace-manifest trusted application and normalizes pnpmConfig.registries.default from explicit registry strings.
pnpm reader tests
config/reader/test/*
Updated tests assert project .npmrc and workspace placeholders are ignored, user-level .npmrc can expand, warnings/fallbacks updated, and workspace-root .npmrc resolution covered.
pacquet NpmrcAuth per-source parsing
pacquet/crates/config/src/npmrc_auth.rs
Adds ParseOptions, from_ini_with_options, and NpmrcAuth::from_project_ini to disable expansion for project .npmrc; parsing suppresses placeholder-bearing auth/registry/proxy entries and records warnings.
pacquet npmrc auth tests
pacquet/crates/config/src/npmrc_auth/tests.rs
Unit tests assert project-mode ignores ${...} in registry, scoped keys, url-scoped keys, auth values, and proxy URLs; trusted-mode still expands placeholders.
Workspace YAML handling
pacquet/crates/config/src/workspace_yaml.rs
Introduces substitute_env_trusted and substitute_env_untrusted; untrusted substitution clears/filters registry destinations containing ${...} while expanding non-registry workspace fields.
Config merging and layering
pacquet/crates/config/src/lib.rs
Config::current resolves workspace earlier, parses project .npmrc in project mode, applies trusted substitutions for global and PNPM_CONFIG_* overlays, and preserves rescope labels for user/project sources.
Rust config tests & workspace subdir
pacquet/crates/config/src/tests.rs
Adds tests for global config env expansion (npmrcAuthFile, config.yaml registries/pnprServer, PNPM_CONFIG_* templates) and for reading workspace-root .npmrc from subdirs.
CLI integration tests
pacquet/crates/cli/tests/install.rs
Replaces project .npmrc env-var registry test with trusted-user-npmrc scenarios using --npmrc-auth-file, validating trusted expansion and that project-level placeholders are ignored.
Workspace override
pnpm-workspace.yaml
Adds a shell-quote overrides rule forcing <1.8.4 to >=1.8.4.

Sequence Diagram(s)

sequenceDiagram
  participant WorkspaceYAML as pnpm-workspace.yaml
  participant ProjectNpmrc as Project .npmrc
  participant UserNpmrc as User/Trusted .npmrc
  participant NpmrcAuth as NpmrcAuth Parser
  participant Config as Config::current

  WorkspaceYAML->>NpmrcAuth: substitute_env_untrusted() (detect ${...})
  alt placeholder in workspace registry
    NpmrcAuth->>Config: drop registry entry + warn
  else no placeholder
    NpmrcAuth->>Config: set registry
  end

  ProjectNpmrc->>NpmrcAuth: from_project_ini(text)
  NpmrcAuth->>NpmrcAuth: detect ${...} in auth/registry
  NpmrcAuth->>Config: omit placeholder-bearing entries + warn

  UserNpmrc->>NpmrcAuth: from_ini(text) (trusted)
  NpmrcAuth->>NpmrcAuth: expand env placeholders
  NpmrcAuth->>Config: apply expanded auth/registry
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related issues

Possibly related PRs

  • pnpm/pnpm#11526: Related .npmrc auth/placeholder handling changes in the config reader.
  • pnpm/pnpm#11953: Related load-time rescoping and .npmrc parsing pipeline changes touching similar code paths.
  • pnpm/pnpm#11730: Related refactors in Config::current and config layering semantics.

Suggested reviewers

  • KSXGitHub

Poem

🐰 I sniffed the configs, found braces and dread,
Project secrets hidden, so safely I fled.
Trusted files I munch, expand env with delight,
Workspace stays tidy — no tokens in sight.
Hop secure, hop quick — registries sleep tight!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title 'fix: block untrusted request destination env expansion' directly summarizes the main change: preventing environment variable placeholder expansion in untrusted (project-level) configuration sources for registry and request destination settings.

✏️ 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 vuln-x77r

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.

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Micro-Benchmark Results

Linux

group                          main                                   pr
-----                          ----                                   --
tarball/download_dependency    1.00      7.8±0.27ms   552.7 KB/sec    1.13      8.8±0.14ms   491.1 KB/sec

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Integrated-Benchmark Report (Linux)

Each scenario reports direct installs and pnpr installs. Bencher consumes pacquet@HEAD and pnpr@HEAD.

Scenario: Isolated linker: fresh restore, cold cache + cold store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 9.995 ± 0.163 9.844 10.350 1.86 ± 0.05
pacquet@main 9.916 ± 0.078 9.814 10.083 1.84 ± 0.04
pnpr@HEAD 5.378 ± 0.120 5.290 5.697 1.00
pnpr@main 5.381 ± 0.139 5.293 5.704 1.00 ± 0.03
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 9.995139170000002,
      "stddev": 0.1625347415029795,
      "median": 9.9492269152,
      "user": 3.16293682,
      "system": 3.2578371400000004,
      "min": 9.8438914192,
      "max": 10.349606784199999,
      "times": [
        9.9971918922,
        9.9485050102,
        10.349606784199999,
        9.8813903432,
        9.8729264822,
        9.9499488202,
        10.0168100202,
        9.8438914192,
        10.2061602862,
        9.8849606422
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 9.9157525703,
      "stddev": 0.07802117435331599,
      "median": 9.908808089199999,
      "user": 3.1964032199999997,
      "system": 3.29710254,
      "min": 9.8142884922,
      "max": 10.0831088272,
      "times": [
        9.9261177452,
        10.0831088272,
        9.9513106462,
        9.896914860199999,
        9.8749885332,
        9.9859934992,
        9.8488735382,
        9.920701318199999,
        9.8142884922,
        9.8552282432
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 5.3783576379,
      "stddev": 0.12022960777483067,
      "median": 5.3415578612,
      "user": 2.5674654199999996,
      "system": 2.9169978399999996,
      "min": 5.289830193199999,
      "max": 5.6966421712,
      "times": [
        5.6966421712,
        5.319927692199999,
        5.328508288199999,
        5.3546074342,
        5.3698977732,
        5.289830193199999,
        5.4447792912,
        5.3077753162,
        5.309305890199999,
        5.362302329199999
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 5.3814268665,
      "stddev": 0.13919532086313352,
      "median": 5.314399808199999,
      "user": 2.56930712,
      "system": 2.8691519399999996,
      "min": 5.2925537972,
      "max": 5.7038997791999995,
      "times": [
        5.399067370199999,
        5.3002908862,
        5.2925537972,
        5.5580533742,
        5.3230461272,
        5.7038997791999995,
        5.311199321199999,
        5.317600295199999,
        5.3106818121999995,
        5.2978759021999995
      ]
    }
  ]
}

Scenario: Isolated linker: fresh restore, hot cache + hot store

Command Mean [ms] Min [ms] Max [ms] Relative
pacquet@HEAD 658.6 ± 12.8 637.1 677.6 1.00
pacquet@main 684.2 ± 64.8 649.6 861.4 1.04 ± 0.10
pnpr@HEAD 789.4 ± 25.8 762.6 834.4 1.20 ± 0.05
pnpr@main 795.1 ± 109.8 737.3 1104.4 1.21 ± 0.17
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 0.6585977243600001,
      "stddev": 0.012778841832479524,
      "median": 0.6579725685600001,
      "user": 0.373954,
      "system": 1.31965266,
      "min": 0.6370568690600001,
      "max": 0.6776199230600001,
      "times": [
        0.6559752550600001,
        0.6453216310600001,
        0.6370568690600001,
        0.6522266360600001,
        0.6776199230600001,
        0.6659120690600001,
        0.6599698820600001,
        0.6620371130600001,
        0.6766065540600001,
        0.6532513110600001
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 0.6842234302600001,
      "stddev": 0.06483568421022756,
      "median": 0.6572127120600001,
      "user": 0.38078860000000003,
      "system": 1.3307805599999998,
      "min": 0.64957815106,
      "max": 0.8613997150600001,
      "times": [
        0.6573318850600001,
        0.7006268500600001,
        0.6604049310600001,
        0.6950677260600001,
        0.8613997150600001,
        0.6570935390600001,
        0.6532024540600001,
        0.64957815106,
        0.6514958800600001,
        0.6560331710600001
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.7894334877600001,
      "stddev": 0.025816696294918687,
      "median": 0.7800560520600002,
      "user": 0.40014779999999994,
      "system": 1.3400728599999998,
      "min": 0.7625719590600001,
      "max": 0.8343663450600001,
      "times": [
        0.7636357510600001,
        0.7727419060600001,
        0.8343663450600001,
        0.7625719590600001,
        0.7963112820600001,
        0.7933979600600001,
        0.7783283570600001,
        0.77779245906,
        0.8334051110600001,
        0.7817837470600001
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.7951062875600001,
      "stddev": 0.10978874440211951,
      "median": 0.7573771115600001,
      "user": 0.3933595999999999,
      "system": 1.34066846,
      "min": 0.7373187370600001,
      "max": 1.10435163306,
      "times": [
        0.7976240480600001,
        0.7620202870600001,
        0.7578147180600001,
        0.7373187370600001,
        1.10435163306,
        0.7729434880600001,
        0.7523837780600001,
        0.75567621106,
        0.7569395050600001,
        0.7539904700600001
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, cold cache + cold store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 9.267 ± 0.033 9.232 9.322 1.82 ± 0.02
pacquet@main 9.263 ± 0.071 9.204 9.457 1.82 ± 0.02
pnpr@HEAD 5.080 ± 0.042 5.030 5.158 1.00
pnpr@main 5.169 ± 0.152 5.006 5.441 1.02 ± 0.03
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 9.2671837046,
      "stddev": 0.03255126534029447,
      "median": 9.2559623808,
      "user": 3.6631851999999996,
      "system": 3.28446818,
      "min": 9.232183128800001,
      "max": 9.3224414528,
      "times": [
        9.302220717800001,
        9.3066956498,
        9.3224414528,
        9.2559101518,
        9.2524701228,
        9.2723591348,
        9.2560146098,
        9.2362133868,
        9.2353286908,
        9.232183128800001
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 9.2625667404,
      "stddev": 0.07086006248289144,
      "median": 9.2429607023,
      "user": 3.6909587999999998,
      "system": 3.24469128,
      "min": 9.2036909858,
      "max": 9.4567648568,
      "times": [
        9.2036909858,
        9.2401684508,
        9.2425509418,
        9.2268390348,
        9.2555689248,
        9.2433704628,
        9.249032207800001,
        9.2770717128,
        9.2306098258,
        9.4567648568
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 5.080075688999999,
      "stddev": 0.04171282891374835,
      "median": 5.0640776263,
      "user": 2.3946572,
      "system": 2.7645821800000006,
      "min": 5.0297632858000005,
      "max": 5.1582606488,
      "times": [
        5.1095442788000005,
        5.0674938098,
        5.0447830538,
        5.1582606488,
        5.0297632858000005,
        5.0426099228000005,
        5.1246296488,
        5.0594440828,
        5.1035667158,
        5.0606614428
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 5.1691243926,
      "stddev": 0.15183418631370277,
      "median": 5.0959060893,
      "user": 2.3887691999999996,
      "system": 2.7996173800000004,
      "min": 5.0064137768,
      "max": 5.4409876058,
      "times": [
        5.2444838558,
        5.0955312358,
        5.0641746778000005,
        5.0064137768,
        5.3307614118,
        5.0962809428,
        5.4409876058,
        5.3209528588,
        5.0529207698,
        5.0387367908
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, hot cache + hot store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 1.475 ± 0.026 1.435 1.516 2.14 ± 0.05
pacquet@main 1.482 ± 0.028 1.456 1.550 2.15 ± 0.05
pnpr@HEAD 0.690 ± 0.008 0.675 0.705 1.00
pnpr@main 0.693 ± 0.034 0.665 0.784 1.00 ± 0.05
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 1.4754108228,
      "stddev": 0.026215752342861196,
      "median": 1.4800953683000002,
      "user": 1.6125555199999997,
      "system": 1.8097438999999997,
      "min": 1.4351198098,
      "max": 1.5157208388,
      "times": [
        1.4914993788000002,
        1.4916580078000001,
        1.4686913578,
        1.4924303828,
        1.5157208388,
        1.4631936448000002,
        1.4515955648,
        1.4979950318000002,
        1.4462042108000002,
        1.4351198098
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 1.4824152724000002,
      "stddev": 0.028174480310372663,
      "median": 1.4737773788000001,
      "user": 1.5857301199999998,
      "system": 1.8454836999999997,
      "min": 1.4562004168,
      "max": 1.5496555558,
      "times": [
        1.4774015128,
        1.5009826138,
        1.4635118148000001,
        1.4562004168,
        1.4723657888000001,
        1.5496555558,
        1.4728618238000002,
        1.4567720808000002,
        1.4997081828,
        1.4746929338
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.6904304455,
      "stddev": 0.008134609693296392,
      "median": 0.6882640763000001,
      "user": 0.34432841999999997,
      "system": 1.3251674999999998,
      "min": 0.6751640648,
      "max": 0.7048161668,
      "times": [
        0.6956974728,
        0.6882035398,
        0.6874234528000001,
        0.6846971518,
        0.6962205618,
        0.6883246128,
        0.7048161668,
        0.6962813978,
        0.6874760338,
        0.6751640648
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.6933444239,
      "stddev": 0.033636233858117225,
      "median": 0.6857998048,
      "user": 0.33571792,
      "system": 1.2956302,
      "min": 0.6652832748,
      "max": 0.7837748778,
      "times": [
        0.6942970598,
        0.6857758768000001,
        0.6741685308,
        0.7837748778,
        0.7043726898,
        0.6858237328,
        0.6865834948,
        0.6652832748,
        0.6791746098,
        0.6741900918
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, cold cache + hot store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 5.087 ± 0.056 5.009 5.195 7.27 ± 0.17
pacquet@main 5.156 ± 0.061 5.075 5.248 7.37 ± 0.17
pnpr@HEAD 0.699 ± 0.014 0.683 0.728 1.00
pnpr@main 0.724 ± 0.069 0.678 0.913 1.04 ± 0.10
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 5.08720432096,
      "stddev": 0.055728499550249125,
      "median": 5.08765515516,
      "user": 1.8281135199999998,
      "system": 1.9661190199999996,
      "min": 5.00888750766,
      "max": 5.195184311659999,
      "times": [
        5.00888750766,
        5.0548066686599995,
        5.02703186666,
        5.08654357866,
        5.195184311659999,
        5.15180770066,
        5.09523288866,
        5.05749772366,
        5.10628423166,
        5.08876673166
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 5.155775925859999,
      "stddev": 0.06089675088645302,
      "median": 5.17839210516,
      "user": 1.8496820200000001,
      "system": 2.00138122,
      "min": 5.075386690659999,
      "max": 5.24813356366,
      "times": [
        5.07761315266,
        5.19175804066,
        5.13784847466,
        5.17231178666,
        5.24813356366,
        5.18632195166,
        5.2061668246599995,
        5.18447242366,
        5.075386690659999,
        5.07774634966
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.6994300332600001,
      "stddev": 0.014248295879442792,
      "median": 0.69555973816,
      "user": 0.34028132,
      "system": 1.32315082,
      "min": 0.6827468836600001,
      "max": 0.7280917266600001,
      "times": [
        0.7280917266600001,
        0.6827468836600001,
        0.69340970166,
        0.7017518396600001,
        0.6977097746600001,
        0.69249871766,
        0.7109915856600001,
        0.6837527916600001,
        0.7127451186600001,
        0.6906021926600001
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.72439780836,
      "stddev": 0.06927590331628163,
      "median": 0.6969156221600001,
      "user": 0.35168232,
      "system": 1.30513132,
      "min": 0.6776775876600001,
      "max": 0.9130948666600001,
      "times": [
        0.73175011166,
        0.68909887566,
        0.6931238146600001,
        0.9130948666600001,
        0.6954692706600001,
        0.7194444176600001,
        0.6872413916600001,
        0.7387157736600001,
        0.6776775876600001,
        0.6983619736600001
      ]
    }
  ]
}

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchpr/12291
Testbedpacquet

🚨 1 Alert

BenchmarkMeasure
Units
ViewBenchmark Result
(Result Δ%)
Upper Boundary
(Limit %)
isolated-linker.fresh-install.cold-cache.cold-storeLatency
seconds (s)
📈 plot
🚷 threshold
🚨 alert (🔔)
9.27 s
(+29.44%)Baseline: 7.16 s
8.59 s
(107.87%)

Click to view all benchmark results
BenchmarkLatencyBenchmark Result
milliseconds (ms)
(Result Δ%)
Upper Boundary
milliseconds (ms)
(Limit %)
isolated-linker.fresh-install.cold-cache.cold-store📈 view plot
🚷 view threshold
🚨 view alert (🔔)
9,267.18 ms
(+29.44%)Baseline: 7,159.34 ms
8,591.21 ms
(107.87%)

isolated-linker.fresh-install.cold-cache.hot-store📈 view plot
🚷 view threshold
5,087.20 ms
(+1.38%)Baseline: 5,018.19 ms
6,021.83 ms
(84.48%)
isolated-linker.fresh-install.hot-cache.hot-store📈 view plot
🚷 view threshold
1,475.41 ms
(+4.66%)Baseline: 1,409.71 ms
1,691.65 ms
(87.22%)
isolated-linker.fresh-restore.cold-cache.cold-store📈 view plot
🚷 view threshold
9,995.14 ms
(+9.29%)Baseline: 9,145.30 ms
10,974.36 ms
(91.08%)
isolated-linker.fresh-restore.hot-cache.hot-store📈 view plot
🚷 view threshold
658.60 ms
(+1.16%)Baseline: 651.03 ms
781.23 ms
(84.30%)
🐰 View full continuous benchmarking report in Bencher

@zkochan zkochan marked this pull request as ready for review June 9, 2026 18:00
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0)

Grey Divider


Action required

1. Trusted registry env regression ✓ Resolved 🐞 Bug ≡ Correctness
Description
In pacquet, WorkspaceSettings::substitute_env() is now used for trusted sources (global
config.yaml and PNPM_CONFIG_* overlay) but it drops registry/namedRegistries entries
containing ${...} instead of expanding them, causing silent fallback to defaults. This breaks
env-based registry configuration in trusted user/global/env config even though the PR intends to
preserve env expansion for trusted sources.
Code

pacquet/crates/config/src/lib.rs[R1629-1633]

+        let mut global_settings =
       global_config_dir.as_deref().map(WorkspaceSettings::load_global).transpose()?.flatten();
+        if let Some(global_settings) = global_settings.as_mut() {
+            global_settings.substitute_env::<Sys>();
+        }
Evidence
Config::current() calls substitute_env() on global_settings (loaded from user config.yaml)
and on env_settings (built from PNPM_CONFIG_*). But WorkspaceSettings::substitute_env() now
explicitly drops registry when it contains an env placeholder and removes named_registries
entries with placeholders, so trusted configs using ${VAR} are discarded rather than expanded.

pacquet/crates/config/src/lib.rs[1625-1633]
pacquet/crates/config/src/lib.rs[1855-1861]
pacquet/crates/config/src/workspace_yaml.rs[649-676]
pacquet/crates/config/src/env_overlay.rs[71-152]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`WorkspaceSettings::substitute_env()` now filters out env placeholders for `registry` and `namedRegistries` (drops those values). `Config::current()` calls this same method for *trusted* sources (global `config.yaml` and the `PNPM_CONFIG_*` env overlay), which makes trusted registry configuration using `${VAR}` stop working and silently fall back to defaults.
## Issue Context
- Filtering registry URL placeholders is correct for repo-controlled sources (e.g. `pnpm-workspace.yaml`).
- But global `config.yaml` and `PNPM_CONFIG_*` are user-controlled/trusted and should continue to support env expansion (per PR description).
## Fix Focus Areas
- pacquet/crates/config/src/lib.rs[1629-1633]
- pacquet/crates/config/src/lib.rs[1855-1861]
- pacquet/crates/config/src/workspace_yaml.rs[649-676]
- pacquet/crates/config/src/env_overlay.rs[71-152]
## Proposed fix
1. Split substitution into two paths:
- **Trusted substitution** (global config + env overlay): expand `${...}` in `registry` and `named_registries` via `env_replace_lossy` (and keep existing expansion for other scalar fields).
- **Untrusted substitution** (workspace yaml): keep current behavior that drops `registry`/`namedRegistries` entries containing `${...}`.
2. Implement as either:
- `substitute_env_trusted()` and `substitute_env_untrusted()` methods, or
- `substitute_env(opts: SubstituteEnvOptions { trust_registry_urls: bool })`.
3. Update `Config::current()` call sites:
- Use trusted variant for `global_settings` and `env_settings`.
- Use untrusted variant only for `pnpm-workspace.yaml` settings.
4. Add/adjust tests to cover `${VAR}` expansion for `registry`/`namedRegistries` in global `config.yaml` and `PNPM_CONFIG_REGISTRY`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Proxy key env bypass 🐞 Bug ⛨ Security
Description
Project-level .npmrc parsing still allows ${VAR} expansion in *keys* into proxy keys (e.g.,
https-proxy) because isRequestDestinationKey()/is_request_destination_key() does not classify
proxy keys as request-destination keys. This enables ${FOO}=http://proxy/ with FOO=https-proxy
to be accepted, weakening the intended boundary that disallows env expansion for project proxy
destinations.
Code

config/reader/src/loadNpmrcFiles.ts[R233-239]

+function isRequestDestinationKey (key: string): boolean {
+  return isRegistryKey(key) || key.startsWith('//')
+}
+
+function isRequestDestinationValueKey (key: string): boolean {
+  return isRegistryKey(key) || key === 'https-proxy' || key === 'http-proxy' || key === 'proxy'
+}
Evidence
The guard for project .npmrc/project ini performs environment substitution on the key (e.g., via
substituteEnv/env_replace_lossy) and then consults
isRequestDestinationKey()/is_request_destination_key(&key) to decide whether to ignore the entry
when project-source request-destination expansion is disabled; however, that helper only matches
registry/URL-scoped request-destination keys and excludes proxy keys. Because proxy keys are
explicitly accepted later (either via isNpmrcReadableKey() allowing
https-proxy/http-proxy/proxy, or via an explicit match key.as_str() handling proxy keys), a
substituted key that becomes a proxy key is retained and applied, creating a key-expansion bypass
path (notably, even though the value-side guard is_request_destination_value_key() includes proxy
keys, the key-side guard does not).

config/reader/src/loadNpmrcFiles.ts[186-239]
config/reader/src/localConfig.ts[17-24]
config/reader/src/localConfig.ts[189-199]
pacquet/crates/config/src/npmrc_auth.rs[211-280]
pacquet/crates/config/src/npmrc_auth.rs[663-669]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Project-level `.npmrc`/project ini parsing is intended to block env-placeholder expansion for “request destination” entries, but the key-side guard relies on `isRequestDestinationKey()`/`is_request_destination_key()` which omits proxy keys (`https-proxy`, `http-proxy`, `proxy`). As a result, placeholders in *keys* can still expand into proxy keys and be accepted/applied, undermining the intended restriction for project-sourced proxy destinations.
## Issue Context
- The project `.npmrc` guard substitutes env vars in the key (e.g., `substituteEnv(rawKey, ...)` / `env_replace_lossy`) and then checks `isRequestDestinationKey(key)` / `is_request_destination_key(&key)` to decide whether to ignore the entry when project-source request-destination expansion is disabled.
- Proxy keys are still considered readable/handled after this point (e.g., `isNpmrcReadableKey()` explicitly allows proxy keys, and the parser also matches proxy keys explicitly in the main `match key.as_str()`), so a substituted key that becomes `https-proxy`/`http-proxy`/`proxy` will be retained.
- The value-side guard `is_request_destination_value_key()` includes proxy keys, but the key-side destination classification does not, enabling the bypass specifically through key expansion.
## Fix Focus Areas
- config/reader/src/loadNpmrcFiles.ts[233-243]
- config/reader/test/index.ts[693-720]
- pacquet/crates/config/src/npmrc_auth.rs[214-256]
- pacquet/crates/config/src/npmrc_auth.rs[663-669]
- pacquet/crates/config/src/npmrc_auth/tests.rs[241-263]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Workspace key env expansion 🐞 Bug ⛨ Security
Description
Workspace settings still run envReplace() on setting *keys*, so a repo-controlled
pnpm-workspace.yaml can use ${VAR} in a key to synthesize sensitive keys like registry even
though value placeholders are now filtered. This preserves env-driven key expansion in untrusted
workspace settings and undermines the “no placeholder expansion for workspace registry fields”
intent.
Code

config/reader/src/getOptionsFromRootManifest.ts[R72-82]

for (const [key, value] of Object.entries(settings)) {
const newKey = envReplace(key, process.env)
if (typeof value === 'string') {
+      if (newKey === 'registry' && hasEnvPlaceholder(value)) continue
// @ts-expect-error
newSettings[newKey as keyof PnpmSettings] = envReplace(value, process.env)
} else if (newKey === 'registries' || newKey === 'namedRegistries') {
-      // Registry URL maps in workspace yaml must support `${VAR}` substitution
-      // in their values so users can reuse the same env-var pattern they use
-      // in `.npmrc`. Only these keys are treated this way to avoid surprising
-      // behavior on unrelated object-valued settings.
-      newSettings[newKey as keyof PnpmSettings] = replaceEnvInStringValues(value) as never
+      newSettings[newKey as keyof PnpmSettings] = copyStringValuesWithoutEnvPlaceholders(value) as never
} else {
newSettings[newKey as keyof PnpmSettings] = value
}
Evidence
Workspace settings processing unconditionally env-expands keys (envReplace(key, process.env)), and
the workspace manifest is fed through getOptionsFromPnpmSettings(), so key placeholders in
pnpm-workspace.yaml can become real config keys.

config/reader/src/getOptionsFromRootManifest.ts[70-83]
config/reader/src/index.ts[875-887]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`replaceEnvInSettings()` still expands environment placeholders in *keys* for workspace/root manifest settings. That means repository-controlled settings can conditionally produce `registry`/`registries`/`namedRegistries` keys via `${VAR}` in the key name, even though value-side placeholder filtering was added.
## Issue Context
- `getOptionsFromPnpmSettings()` is applied to `workspaceManifest` (from `pnpm-workspace.yaml`) before copying camelCase keys into the effective config.
- Current filtering focuses on placeholder-containing values for `registry` and registry maps; it does not prevent placeholder expansion in keys.
## Fix Focus Areas
- config/reader/src/getOptionsFromRootManifest.ts[70-83]
- config/reader/src/index.ts[875-903]
## Implementation guidance
Option A (minimal):
- If `hasEnvPlaceholder(key)` and the expanded `newKey` is one of `registry`, `registries`, or `namedRegistries`, skip it (and ideally emit a warning so users understand why it was ignored).
Option B (stricter/safer):
- Stop applying `envReplace()` to keys altogether for repo-controlled workspace settings, and only apply env replacement to values for explicitly trusted locations.
Add a regression test:
- A `pnpm-workspace.yaml` (or equivalent settings object in test) with a key like `${FOO}: https://attacker.example/` and `FOO=registry` should not set `config.registry`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Block env placeholder expansion in project-controlled registry/auth config
🐞 Bug fix 🧪 Tests ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

Walkthroughs

Description
• Stop expanding ${VAR} in repo-controlled registry/proxy URLs and registry credentials.
• Keep env expansion for trusted user/global/auth.ini/CLI config to preserve token workflows.
• Add regression tests in pnpm config reader and pacquet for trust-boundary behavior.
Diagram
graph TD
A["Project config (.npmrc, workspace)"] --> B(["pnpm config reader"]) --> E{{"Registry/proxy requests"}}
C["Trusted config (user/auth.ini/CLI)"] --> B(["pnpm config reader"]) --> E{{"Registry/proxy requests"}}
A["Project config (.npmrc, workspace)"] --> D(["pacquet config parser"]) --> E{{"Registry/proxy requests"}}
C["Trusted config (user/auth.ini/CLI)"] --> D(["pacquet config parser"]) --> E{{"Registry/proxy requests"}}
subgraph Legend
  direction LR
  _file["Config source"] ~~~ _svc(["Parser"]) ~~~ _ext{{"Outbound requests"}}
end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Disable env expansion everywhere
  • ➕ Simplest threat model: no config-sourced env leaks anywhere
  • ➕ Avoids future edge cases around “trusted vs untrusted” classification
  • ➖ Breaks common CI/user flows that rely on ${NODE_AUTH_TOKEN} in user/auth.ini/CLI config
  • ➖ Forces all users onto different configuration patterns immediately
2. Project-level opt-in allowlist for env expansion
  • ➕ Keeps backward compatibility for repos that legitimately used dynamic registries
  • ➕ Allows gradual migration with explicit intent
  • ➖ Adds new policy surface area that can be misconfigured
  • ➖ Hard to make safe by default (reviewers may miss that a repo opted in)
3. Expand but sanitize/denylist sensitive variables
  • ➕ Preserves more historical behavior without fully blocking expansion
  • ➕ Could target only known-dangerous env vars (tokens, creds)
  • ➖ Denylist is incomplete by nature; secrets appear under arbitrary env names
  • ➖ Still allows data exfiltration via non-denylisted variables

Recommendation: Keep the PR’s trust-boundary approach: treat repository-controlled config as untrusted and filter env placeholders for request destinations and credentials, while preserving expansion for user/global/auth.ini/CLI sources. This balances security (prevents env exfiltration via repo config) with compatibility (existing trusted token workflows continue to work).

Grey Divider

File Changes

Bug fix (5)
getOptionsFromRootManifest.ts Filter env placeholders in workspace registry settings +9/-7

Filter env placeholders in workspace registry settings

• Stops env placeholder expansion for the top-level 'registry' setting and for 'registries'/'namedRegistries' string maps in pnpm settings (e.g., from pnpm-workspace.yaml). Values containing '${...}' are now dropped instead of substituted.

config/reader/src/getOptionsFromRootManifest.ts


loadNpmrcFiles.ts Make .npmrc env expansion trust-aware for registry/auth/proxy +70/-6

Make .npmrc env expansion trust-aware for registry/auth/proxy

• Introduces per-source options to disable env expansion for project/workspace '.npmrc' on request destinations (registry, proxies, URL-scoped keys) and auth values. Adds helper key classification and emits targeted warnings when placeholders are ignored.

config/reader/src/loadNpmrcFiles.ts


lib.rs Parse project .npmrc with untrusted rules in pacquet +11/-7

Parse project .npmrc with untrusted rules in pacquet

• Splits parsing into trusted vs project sources: project '.npmrc' now uses 'from_project_ini' (no env expansion for request URLs/auth values), while user '.npmrc' and 'auth.ini' remain trusted. Keeps per-source rescoping behavior.

pacquet/crates/config/src/lib.rs


npmrc_auth.rs Add trust-aware .npmrc parsing options and warnings +101/-0

Add trust-aware .npmrc parsing options and warnings

• Introduces parse options controlling env expansion for auth values and request destinations, and a dedicated 'from_project_ini' constructor. Blocks placeholder expansion for registry/proxy destinations and auth keys/values in untrusted mode and emits explicit warnings when ignored.

pacquet/crates/config/src/npmrc_auth.rs


workspace_yaml.rs Stop env placeholder substitution in workspace registry URLs +13/-12

Stop env placeholder substitution in workspace registry URLs

• Removes env substitution for workspace registry fields and instead drops any 'registry'/'namedRegistries' entries containing '${...}' before applying settings to config. Aligns workspace YAML behavior with upstream trust boundary.

pacquet/crates/config/src/workspace_yaml.rs


Tests (5)
getOptionsFromRootManifest.test.ts Update workspace settings env-expansion expectations +14/-4

Update workspace settings env-expansion expectations

• Changes tests to assert that '${...}' in 'registries', 'namedRegistries', and 'registry' settings are ignored/dropped rather than expanded. Adds coverage for the 'registry' setting specifically.

config/reader/test/getOptionsFromRootManifest.test.ts


index.ts Add regression tests for project vs user .npmrc env expansion +177/-12

Add regression tests for project vs user .npmrc env expansion

• Adds Jest cases ensuring project '.npmrc' does not expand placeholders in registry/scoped registry, URL-scoped keys, auth values, or proxy URLs, and that warnings are produced. Confirms user '.npmrc' may still expand placeholders and adjusts existing auth-placeholder tests to use userconfig.

config/reader/test/index.ts


install.rs E2E: env expansion allowed only in trusted user .npmrc +50/-23

E2E: env expansion allowed only in trusted user .npmrc

• Updates the install test to move registry '${VAR}' substitution into a trusted user '.npmrc' provided via '--npmrc-auth-file'. Adds a new test asserting project '.npmrc' registry placeholders are ignored even when the environment is set.

pacquet/crates/cli/tests/install.rs


tests.rs Unit tests for trusted vs project .npmrc placeholder handling +113/-0

Unit tests for trusted vs project .npmrc placeholder handling

• Adds tests verifying project '.npmrc' ignores '${VAR}' in registries, URL-scoped keys, auth values, and proxy URLs, while trusted '.npmrc' expands registry placeholders. Ensures filtered placeholders don’t reach derived config/auth headers.

pacquet/crates/config/src/npmrc_auth/tests.rs


tests.rs Update workspace YAML tests to assert placeholders are ignored +11/-10

Update workspace YAML tests to assert placeholders are ignored

• Replaces the prior expectation of env substitution in 'namedRegistries' with assertions that workspace registry placeholders are filtered out. Confirms default registry remains unchanged when placeholders are present.

pacquet/crates/config/src/workspace_yaml/tests.rs


Documentation (1)
sharp-registry-env-placeholders.md Document trust-aware env placeholder behavior change +6/-0

Document trust-aware env placeholder behavior change

• Adds a changeset describing that env placeholders are no longer expanded in repo-controlled registry/proxy URLs and credential values. Directs users to move dynamic registry/token setup to trusted config sources.

.changeset/sharp-registry-env-placeholders.md


Other (2)
pnpm-lock.yaml Override shell-quote to patched version +5/-4

Override shell-quote to patched version

• Adds an override constraint and updates the lockfile to resolve 'shell-quote' from 1.8.3 to 1.8.4 to avoid the vulnerable version.

pnpm-lock.yaml


pnpm-workspace.yaml Add workspace override for shell-quote <1.8.4 +1/-0

Add workspace override for shell-quote <1.8.4

• Adds an overrides entry requiring 'shell-quote' >=1.8.4 for affected dependency ranges, ensuring consumers do not pull the vulnerable version.

pnpm-workspace.yaml


Grey Divider

Qodo Logo

@codecov-commenter

codecov-commenter commented Jun 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.75000% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.54%. Comparing base (d188316) to head (9ec847d).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
pacquet/crates/config/src/npmrc_auth.rs 88.88% 9 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #12291      +/-   ##
==========================================
- Coverage   87.55%   87.54%   -0.02%     
==========================================
  Files         280      288       +8     
  Lines       33664    34952    +1288     
==========================================
+ Hits        29476    30599    +1123     
- Misses       4188     4353     +165     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@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: 3

🧹 Nitpick comments (2)
config/reader/src/loadNpmrcFiles.ts (1)

188-217: ⚡ Quick win

Consider adding a comment explaining the two-pass filtering approach.

The logic correctly handles keys that match patterns before or after env substitution, but the reasoning is non-obvious. A brief comment would help future maintainers understand why checking both rawKey and the substituted key is necessary.

Example:

// Two-pass filtering:
// 1. Check rawKey before substitution (catches patterns with literal ${...})
// 2. Substitute, then check if original rawKey had placeholders and substituted key matches pattern
//    (catches keys like ${HOST}/:_authToken that become //example.com/:_authToken)
🤖 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 `@config/reader/src/loadNpmrcFiles.ts` around lines 188 - 217, Add a short
comment above the two-pass filtering logic in loadNpmrcFiles.ts explaining why
we check both rawKey and the substituted key: note that we first check rawKey
(to catch keys that literally contain ${...} placeholders) and then substitute
via substituteEnv into key and re-check (to catch cases where substitution
produces a pattern that matches isRequestDestinationKey / isAuthValueKey /
isRequestDestinationValueKey); mention that this prevents expanding env
placeholders when expandRequestDestinationEnv or expandAuthValueEnv is false and
reference the related symbols rawKey, key, substituteEnv, hasEnvPlaceholder,
isRequestDestinationKey, isAuthValueKey, isRequestDestinationValueKey, and the
warnIgnored* helpers so future readers understand the intent of both checks.
pacquet/crates/config/src/workspace_yaml/tests.rs (1)

191-191: ⚡ Quick win

Assert “unchanged default” instead of hardcoding the registry URL.

This test is about ignoring env placeholders, not pinning Config::new()’s default registry literal. Capture before_registry and assert it is unchanged after apply_to to avoid brittle failures if defaults change.

Suggested diff
     let mut settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap();
     settings.substitute_env::<EnvWithHost>();
     let mut config = Config::new();
+    let before_registry = config.registry.clone();
     settings.apply_to(&mut config, Path::new("/irrelevant"));
-    assert_eq!(config.registry, "https://registry.npmjs.org/");
+    assert_eq!(config.registry, before_registry);
🤖 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 `@pacquet/crates/config/src/workspace_yaml/tests.rs` at line 191, The test
currently asserts a hardcoded default registry URL; instead capture the default
before applying changes and assert it remains unchanged after apply_to. Update
the test to call Config::new() (or access the initial default) into a
before_registry variable, call apply_to, then assert_eq!(config.registry,
before_registry) so the test checks "unchanged default" rather than a brittle
literal; reference Config::new(), apply_to, and config.registry to locate and
modify the assertions.
🤖 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 `@pacquet/crates/config/src/lib.rs`:
- Around line 1655-1664: The project `.npmrc` is being read from start_dir
causing workspace-root configs to be missed; update the code that builds
project_source (the read_npmrc call and the NpmrcAuth::from_project_ini::<Sys>
invocation) so it reads from the resolved workspace root (the resolved
workspaceDir/localPrefix used elsewhere) instead of start_dir — e.g., ensure you
call read_npmrc and NpmrcAuth::from_project_ini with the resolved workspace
directory variable (or move this project_source creation until after workspace
resolution) and keep the same rescope_unscoped("<project>/.npmrc") behavior.

In `@pacquet/crates/config/src/npmrc_auth.rs`:
- Around line 214-255: has_env_placeholder is too permissive (it treats any
literal "${" as a placeholder) causing legitimate values with "${" fragments to
be dropped; change its implementation to detect complete pnpm-style placeholders
only (e.g., a pattern matching "${...}" with a non-empty interior) instead of
contains("${"), and update any other uses (the other occurrence around lines
675-677) to rely on the tightened has_env_placeholder behavior; keep existing
callers (env_replace_lossy, the checks using
is_request_destination_key/is_auth_value_key/is_request_destination_value_key
and the auth.warn_ignored_* calls) unchanged so they only trigger when a full
"${...}" placeholder is present.

In `@pacquet/crates/config/src/workspace_yaml.rs`:
- Around line 648-663: substitute_env currently drops `${...}` only for registry
fields but no longer uses Sys so ordinary string settings aren't expanded;
restore upstream behavior by making substitute_env accept the Sys: EnvVar
parameter again and use it to expand environment placeholders for non-registry
workspace/global string fields while still filtering registry and
named_registries (i.e., keep the existing registry check that sets self.registry
= None and the named_registries.retain call, but also walk the other string
fields (storeDir, cacheDir, pnprServer, scriptShell, etc. on self) and replace
`${...}` using the Sys expansion method before calling Self::apply_to so Config
gets expanded values).

---

Nitpick comments:
In `@config/reader/src/loadNpmrcFiles.ts`:
- Around line 188-217: Add a short comment above the two-pass filtering logic in
loadNpmrcFiles.ts explaining why we check both rawKey and the substituted key:
note that we first check rawKey (to catch keys that literally contain ${...}
placeholders) and then substitute via substituteEnv into key and re-check (to
catch cases where substitution produces a pattern that matches
isRequestDestinationKey / isAuthValueKey / isRequestDestinationValueKey);
mention that this prevents expanding env placeholders when
expandRequestDestinationEnv or expandAuthValueEnv is false and reference the
related symbols rawKey, key, substituteEnv, hasEnvPlaceholder,
isRequestDestinationKey, isAuthValueKey, isRequestDestinationValueKey, and the
warnIgnored* helpers so future readers understand the intent of both checks.

In `@pacquet/crates/config/src/workspace_yaml/tests.rs`:
- Line 191: The test currently asserts a hardcoded default registry URL; instead
capture the default before applying changes and assert it remains unchanged
after apply_to. Update the test to call Config::new() (or access the initial
default) into a before_registry variable, call apply_to, then
assert_eq!(config.registry, before_registry) so the test checks "unchanged
default" rather than a brittle literal; reference Config::new(), apply_to, and
config.registry to locate and modify the assertions.
🪄 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: dd0eeb8e-74b2-45c3-a970-7ce21eddf708

📥 Commits

Reviewing files that changed from the base of the PR and between ea204b0 and 5cb2b25.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (12)
  • .changeset/sharp-registry-env-placeholders.md
  • config/reader/src/getOptionsFromRootManifest.ts
  • config/reader/src/loadNpmrcFiles.ts
  • config/reader/test/getOptionsFromRootManifest.test.ts
  • config/reader/test/index.ts
  • pacquet/crates/cli/tests/install.rs
  • pacquet/crates/config/src/lib.rs
  • pacquet/crates/config/src/npmrc_auth.rs
  • pacquet/crates/config/src/npmrc_auth/tests.rs
  • pacquet/crates/config/src/workspace_yaml.rs
  • pacquet/crates/config/src/workspace_yaml/tests.rs
  • pnpm-workspace.yaml
📜 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). (4)
  • GitHub Check: Lint and Test (macos-latest)
  • GitHub Check: Lint and Test (windows-latest)
  • GitHub Check: Run benchmark on ubuntu-latest
  • GitHub Check: Analyze (javascript)
🧰 Additional context used
📓 Path-based instructions (4)
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx,js,jsx}: Use Standard Style with trailing commas, prefer functions over classes, declare functions after they are used (relying on hoisting), limit functions to no more than two or three arguments, and use a single options object for functions needing more parameters
Follow import order: standard libraries first, then external dependencies (sorted alphabetically), then relative imports
Do not write comments that restate what the code already says; rename variables, split helpers, or move checks to more obvious places instead
Do not repeat documentation at call sites that already lives on the callee; update the JSDoc once and let every call site benefit
Use JSDoc for the function's contract (preconditions, postconditions, edge cases, why the function exists), not for re-narrating the function body
Do not record past implementation shape, refactor history, or removed code in comments; use git log and git blame for that information instead
Write comments only when the reason for code is non-obvious, a hidden invariant exists, a workaround for a known bug is needed, or an exception to surrounding pattern is deliberate

Files:

  • config/reader/test/getOptionsFromRootManifest.test.ts
  • config/reader/src/getOptionsFromRootManifest.ts
  • config/reader/test/index.ts
  • config/reader/src/loadNpmrcFiles.ts
**/*.test.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Do not use instanceof Error for checking if a caught error is an Error object in Jest tests; use util.types.isNativeError() instead to work across realms

Files:

  • config/reader/test/getOptionsFromRootManifest.test.ts
pacquet/**/*.rs

📄 CodeRabbit inference engine (pacquet/AGENTS.md)

pacquet/**/*.rs: Log emissions are part of matching pnpm — when porting a function that fires pnpm:<channel> events through globalLogger, logger.debug(...), or streamParser.write(...), mirror the call site, payload, and ordering so @pnpm/cli.default-reporter parses pacquet's NDJSON the same way
Declare a newtype wrapper for branded string types instead of collapsing the brand into a plain String or &str in Rust
If upstream TypeScript always validates before construction of a branded string, validate in the Rust wrapper too via TryFrom<String> and/or FromStr and do not provide an infallible public constructor
If upstream TypeScript never validates a branded string, just brand for type-safety in Rust by exposing an infallible From<String> constructor
If upstream TypeScript occasionally constructs a branded string without validation, expose from_str_unchecked in Rust as an escape hatch alongside the validating constructor
Match upstream serde behavior for branded strings crossing JSON, YAML, or INI boundaries by using #[serde(try_from = "String")] for deserialization and #[serde(into = "String")] for serialization
Derive simple conversions for branded strings using #[derive(derive_more::From)] and #[derive(derive_more::Into)] instead of handwriting impl blocks; use manual impl only when conversion needs custom logic
Model TypeScript string literal unions (like 'auto' | 'always' | 'never') as Rust enums instead of newtype wrappers, since the set of valid values is closed
Treat TypeScript string template literal types (like `${string}@${string}`) the same as branded string types in Rust, using a newtype wrapper with validation
Follow the code style guide in CODE_STYLE_GUIDE.md — imports, modules, naming, ownership and borrowing, parameter type selection, trait bounds, pattern matching, pipe-trait, error handling, test layout, and cloning of Arc and Rc
Choose owned vs. borrowed parameters to minimize copies; widen to t...

Files:

  • pacquet/crates/config/src/lib.rs
  • pacquet/crates/config/src/workspace_yaml.rs
  • pacquet/crates/config/src/workspace_yaml/tests.rs
  • pacquet/crates/config/src/npmrc_auth/tests.rs
  • pacquet/crates/config/src/npmrc_auth.rs
  • pacquet/crates/cli/tests/install.rs
pacquet/**/tests/**/*.rs

📄 CodeRabbit inference engine (pacquet/AGENTS.md)

pacquet/**/tests/**/*.rs: Prefer real fixtures over dependency-injection seams — use tempfile::TempDir, the mocked registry, or integration tests spawning the actual binary for happy paths and most error paths; use the DI seam only for filesystem error kinds, deterministic time, shared process-global state, or external-service happy paths
Follow test-logging guidance — log before non-assert_eq! assertions, dbg! complex structures, skip logging for simple scalar assert_eq!
Tests must not be tolerant of missing build/runtime environment by silently returning early when a tool isn't found — let the test panic when a required tool is absent
Prefer #[cfg_attr(target_os = "windows", ignore = "...")] (or matching #[cfg(unix)] gates) over runtime probe-and-skip helpers for platform-locked tools
Port relevant pnpm tests to Rust tests whenever they translate when porting behavior from pnpm
Use snapshot tests with insta and carefully review diffs when intentional changes alter snapshots; accept with cargo insta review only after careful review
Tests that need the mocked registry should start pnpr through pacquet-testing-utils; cargo test / cargo nextest run should not require a separate just registry-mock launch step

Files:

  • pacquet/crates/cli/tests/install.rs
🧠 Learnings (8)
📚 Learning: 2026-05-26T21:01:06.666Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11966
File: .changeset/require-tarball-integrity.md:6-6
Timestamp: 2026-05-26T21:01:06.666Z
Learning: In pnpm lockfile-related release notes/docs (especially changeset markdown), preserve URL hostnames exactly as they appear in pnpm-lock.yaml tarball resolution entries—keep hosts like `codeload.github.com`, `bitbucket.org`, and `gitlab.com` in lowercase. Do not “correct” them to title-case/preserve brand capitalization (e.g., LanguageTool rules like `GITHUB` capitalization) because these are literal URL fragments, not platform brand names.

Applied to files:

  • .changeset/sharp-registry-env-placeholders.md
📚 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:

  • config/reader/test/getOptionsFromRootManifest.test.ts
  • config/reader/src/getOptionsFromRootManifest.ts
  • config/reader/test/index.ts
  • config/reader/src/loadNpmrcFiles.ts
📚 Learning: 2026-06-05T13:47:05.929Z
Learnt from: vsumner
Repo: pnpm/pnpm PR: 12190
File: installing/deps-installer/test/install/frozenStore.ts:2-17
Timestamp: 2026-06-05T13:47:05.929Z
Learning: In the pnpm/pnpm repository, the shared Jest preset keeps `injectGlobals` at its default (`true`), so `test` and `expect` are available as Jest globals. Therefore, reviewers should not flag (or treat as TypeScript/compilation errors) missing `import { test, expect } from 'jest/globals'` when a test file uses `test`/`expect` without importing them. Importing from `jest/globals` may still be used for consistency with sibling files, but it is not required for execution in this repo unless a Jest preset is explicitly configured with `injectGlobals: false`.

Applied to files:

  • config/reader/test/getOptionsFromRootManifest.test.ts
  • config/reader/test/index.ts
📚 Learning: 2026-06-05T13:47:26.046Z
Learnt from: vsumner
Repo: pnpm/pnpm PR: 12190
File: installing/deps-installer/src/install/index.ts:2337-2343
Timestamp: 2026-06-05T13:47:26.046Z
Learning: In the pnpm/pnpm codebase, `PnpmError` automatically prefixes `err.code` with `ERR_PNPM_` when you pass a code that does not already start with `ERR_PNPM_` (it normalizes `this.code` via `code.startsWith('ERR_PNPM_') ? code : `ERR_PNPM_${code}``). Therefore, during code review you should NOT flag `new PnpmError(...)` call sites for passing a bare error code (e.g., `new PnpmError('FROZEN_STORE_INCOMPATIBLE_WITH_PNPR', ...)`); the resulting `err.code` will still be `ERR_PNPM_FROZEN_STORE_INCOMPATIBLE_WITH_PNPR`.

Applied to files:

  • config/reader/test/getOptionsFromRootManifest.test.ts
  • config/reader/src/getOptionsFromRootManifest.ts
  • config/reader/test/index.ts
  • config/reader/src/loadNpmrcFiles.ts
📚 Learning: 2026-05-20T19:40:55.051Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11774
File: pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs:0-0
Timestamp: 2026-05-20T19:40:55.051Z
Learning: In the pacquet Rust code, ensure the semver implementation uses the `node-semver` crate (not `nodejs-semver`). `node-semver`’s public API does not include a `satisfies_with_prerelease`-style method; prerelease-tolerant matching should be implemented inline by first calling `Range::satisfies`, and when it rejects a prerelease version, retry matching against a stripped `MAJOR.MINOR.PATCH` base of the prerelease version.

Applied to files:

  • pacquet/crates/config/src/lib.rs
  • pacquet/crates/config/src/workspace_yaml.rs
  • pacquet/crates/config/src/workspace_yaml/tests.rs
  • pacquet/crates/config/src/npmrc_auth/tests.rs
  • pacquet/crates/config/src/npmrc_auth.rs
  • pacquet/crates/cli/tests/install.rs
📚 Learning: 2026-05-22T00:08:44.646Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11837
File: pacquet/crates/resolving-npm-resolver/src/pick_package.rs:33-51
Timestamp: 2026-05-22T00:08:44.646Z
Learning: In the pnpm/pnpm repo’s pacquet Rust crates, do not flag Unicode ellipsis characters (U+2026, `…`) in Rust doc comments (`///` / `/** */`) as a lint violation. The pacquet crate’s `dylint.toml` only enables `perfectionist::derive_ordering`, and the Dylint `unicode-ellipsis` rule is not enabled for this project—so `…` in doc comments is an intentional, repo-consistent style.

Applied to files:

  • pacquet/crates/config/src/lib.rs
  • pacquet/crates/config/src/workspace_yaml.rs
  • pacquet/crates/config/src/workspace_yaml/tests.rs
  • pacquet/crates/config/src/npmrc_auth/tests.rs
  • pacquet/crates/config/src/npmrc_auth.rs
  • pacquet/crates/cli/tests/install.rs
📚 Learning: 2026-05-20T23:07:58.444Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11784
File: pacquet/crates/resolving-deps-resolver/src/hoist_peers.rs:120-133
Timestamp: 2026-05-20T23:07:58.444Z
Learning: When reviewing code in this pacquet Rust port, follow the upstream pnpm compatibility rule: only match pnpm’s behavior exactly. Do not propose review changes that intentionally deviate from pnpm’s documented/observed behavior, even if pnpm appears buggy. If you identify a real bug in pnpm behavior, the review should prioritize fixing it upstream in pnpm first, and avoid implementing a pnpm-behavior workaround here unless the same fix has already landed upstream.

Applied to files:

  • pacquet/crates/config/src/lib.rs
  • pacquet/crates/config/src/workspace_yaml.rs
  • pacquet/crates/config/src/workspace_yaml/tests.rs
  • pacquet/crates/config/src/npmrc_auth/tests.rs
  • pacquet/crates/config/src/npmrc_auth.rs
  • pacquet/crates/cli/tests/install.rs
📚 Learning: 2026-06-06T18:58:37.156Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 12243
File: pacquet/crates/package-manager/src/install_package_by_snapshot.rs:319-322
Timestamp: 2026-06-06T18:58:37.156Z
Learning: When reviewing Rust code, do not assume `matches!(expr, Pattern(_))` will move out of `expr` if `Pattern(_)` contains no by-value bindings. `matches!` desugars to a `match` that auto-borrows the scrutinee for discrimination, so even if `expr` is a non-`Copy` value behind a shared reference (e.g., `&T`), the macro should not move-out of the borrowed data purely due to `matches!`. Treat `matches!(&expr, Pattern(_))` as a readability/clarity improvement, not a correctness requirement. Only flag potential move-out-of-borrow risks when the pattern includes by-value bindings that would require moving the matched value.

Applied to files:

  • pacquet/crates/config/src/lib.rs
  • pacquet/crates/config/src/workspace_yaml.rs
  • pacquet/crates/config/src/workspace_yaml/tests.rs
  • pacquet/crates/config/src/npmrc_auth/tests.rs
  • pacquet/crates/config/src/npmrc_auth.rs
  • pacquet/crates/cli/tests/install.rs
🔇 Additional comments (16)
.changeset/sharp-registry-env-placeholders.md (1)

1-6: LGTM!

pnpm-workspace.yaml (1)

439-439: LGTM!

config/reader/src/getOptionsFromRootManifest.ts (3)

75-77: LGTM!


87-95: LGTM!


97-99: LGTM!

config/reader/src/loadNpmrcFiles.ts (5)

47-50: LGTM!


64-69: LGTM!


233-243: LGTM!


252-262: LGTM!


245-250: Consider blocking env-placeholder expansion for inline cert/key in project .npmrc

Workspace .npmrc is loaded with expandAuthValueEnv: false, but the guard that ignores ${...} is based on isAuthValueKey, which only treats _authToken, _auth, _password, username, and tokenHelper as auth-value keys. Inline client certificate settings cert/key are excluded from this guard, so ${...} inside cert=/key= in a repository-controlled .npmrc will be expanded and then pinned/rescoped like other per-registry settings.

Confirm whether ${...} expansion should be blocked for cert/key too (e.g., extend the auth-value key set or add a dedicated check), or document the rationale for allowing it given they carry client identity.

config/reader/test/getOptionsFromRootManifest.test.ts (1)

20-49: LGTM!

config/reader/test/index.ts (3)

610-629: LGTM!


722-740: LGTM!


780-804: LGTM!

pacquet/crates/cli/tests/install.rs (2)

257-297: LGTM!


299-340: LGTM!

Comment thread pacquet/crates/config/src/lib.rs
Comment thread pacquet/crates/config/src/npmrc_auth.rs
Comment thread pacquet/crates/config/src/workspace_yaml.rs Outdated
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 5320d4d

@zkochan

zkochan commented Jun 9, 2026

Copy link
Copy Markdown
Member Author

Also addressed the non-inline CodeRabbit note about inline cert/key: project .npmrc now treats top-level and URL-scoped inline client certificate/key values as auth-value material for env-placeholder filtering, with matching pnpm and pacquet tests in 5320d4d.


Written by an agent (Codex, GPT-5).

Comment thread pacquet/crates/config/src/lib.rs
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit b89cea5

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 9ec847d

@zkochan zkochan changed the title fix: block project registry env placeholder expansion fix: block untrusted request destination env expansion Jun 9, 2026
@zkochan zkochan merged commit 1017c36 into main Jun 9, 2026
24 of 25 checks passed
@zkochan zkochan deleted the vuln-x77r branch June 9, 2026 20:29
zkochan added a commit that referenced this pull request Jun 10, 2026
* fix(package-bins): reject reserved manifest bin names

Manifest bin keys "", ".", "..", and scoped forms such as "@scope/.."
passed the bin-name guard because encodeURIComponent leaves them
unchanged. When joined to the global bin directory during global
remove/update/add operations, "." resolves to the bin directory itself
and ".." to its parent, which removeBin then recursively deletes.

Reject empty, ".", and ".." bin names after scope stripping.

Backport of #12289 to v10.

* fix: block untrusted request destination env expansion

Makes environment expansion trust-aware for registry/auth config and
request destinations:

- Stops project and workspace .npmrc files from expanding ${...}
  placeholders in registry/proxy request destinations, URL-scoped keys,
  and registry credential values.
- Stops repository-controlled pnpm-workspace.yaml from expanding
  ${...} placeholders in the registry setting.
- Preserves env expansion for trusted user/global/CLI/env config so
  existing token and registry setup flows continue to work.

Backport of #12291 (CAND-PNPM-122 / GHSA-3qhv-2rgh-x77r) to v10.

* fix(security): verify npm registry signature before spawning a package-manager binary

The packageManager field (and pnpm self-update) makes pnpm download and
run a specific pnpm version. The staged install's bytes were trusted
based on lockfile integrity alone, which proves nothing when the inputs
are repository-controlled.

pnpm now verifies the npm registry signature of the engine it is about
to spawn, over the installed integrity, against npm's public signing
keys embedded in the pnpm CLI (exactly as corepack does):

- verifyPnpmEngineIdentity() checks pnpm/@pnpm/exe and the materialized
  platform binaries of the staged install before it is linked into the
  tools directory.
- Fails closed: any verification failure, including an unreachable
  registry, refuses the version switch rather than running an unverified
  binary. Runs only on a tools-directory cache miss (an actual
  download).
- The embedded keys live in a generated file kept in sync with npm's
  keys endpoint by scripts/update-npm-signing-keys.mjs; the release
  workflow runs the check as a gate so a key rotation cannot silently
  break verification.

Backport of #12292 (CAND-PNPM-097) to v10.

* fix: harden package-manager bootstrap metadata

Resolve package-manager bootstrap traffic through trusted user/CLI
registries and trusted network config, defaulting to the public npm
registry instead of project/workspace registry settings:

- getConfig() now computes packageManagerRegistries and
  packageManagerNetworkConfig from trusted config sources only (CLI
  options, env config, user and global .npmrc) — never the repository's
  project/workspace .npmrc or pnpm-workspace.yaml.
- switchCliVersion() applies that bootstrap config when installing and
  verifying the wanted pnpm version, so repository .npmrc
  proxy/TLS/registry values cannot steer package-manager bootstrap
  traffic.

Backport of #12296 to v10. The v11 env-lockfile validation
parts do not apply: v10 bootstraps the wanted version through a staged
child install instead of an env lockfile.

* fix(security): verify Node.js runtime SHASUMS OpenPGP signature

When a repository requests a Node.js runtime (useNodeVersion or an
execution env), pnpm downloads and then executes a Node binary. The
download mirror is repository-configurable via node-mirror:<channel> in
project .npmrc, and the integrity came from SHASUMS256.txt fetched from
that same mirror — a circular check a malicious mirror can satisfy with
a tampered binary and matching hashes.

pnpm now fetches SHASUMS256.txt.sig and verifies its detached OpenPGP
signature against the Node.js release team's public keys, embedded in
the pnpm CLI, before trusting the hashes:

- @pnpm/crypto.shasums-file: new fetchVerifiedNodeShasums /
  fetchVerifiedNodeShasumsFile verify the signature via openpgp against
  the embedded keys (generated src/nodeReleaseKeys.ts, mirrored from
  the canonical nodejs/release-keys list).
- @pnpm/node.fetcher verifies the configurable-mirror SHASUMS for the
  release channel; pre-release channels (rc, nightly, ...) are unsigned
  by Node and remain unverified.
- scripts/update-node-release-keys.mjs keeps the keys current
  (pnpm run check:node-release-keys / update:node-release-keys), and
  the release workflow runs the check as a gate.

Backport of #12295 to v10 (without the pacquet Rust port,
which does not exist on this branch).

* test(env): sign the SHASUMS fixture for Node.js download tests

The Node.js download tests exercise the release channel, whose
SHASUMS256.txt is now signature-verified. Sign the fixture with a
generated OpenPGP key and trust it through the new
trustedNodeReleaseKeys test seam (threaded from plugin-commands-env via
@pnpm/node.fetcher to fetchVerifiedNodeShasums), so the tests keep
exercising the verification path instead of bypassing it.

* fix(self-updater): redact registry credentials from engine identity errors

Registry URLs may legally embed basic-auth credentials
(https://user:pass@host/). verifyPnpmEngineIdentity() interpolated the
packument URL and registry URL into PnpmError messages, and the
unreachable-registry path surfaced fetch-layer error messages that embed
the request URL — all of which land in terminal output and CI logs.
Strip URL credentials from every error message and truncate the non-200
response body.

* fix: update vulnerable transitive dependencies

Override shell-quote to >=1.8.4 (GHSA-w7jw-789q-3m8p, critical, pulled
in via concurrently) so the audit workflow passes again. The advisory
was published after the last release/10 audit run; it is unrelated to
the security backports on this branch.
@zkochan

zkochan commented Jun 10, 2026

Copy link
Copy Markdown
Member Author

🚢 v11.5.3

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants