{
  "version": "https://jsonfeed.org/version/1",
  "title": "Pnpm on LLBBL Blog",
  "icon": "https://avatars.micro.blog/avatars/2023/40/125738.jpg",
  "home_page_url": "https://llbbl.blog/",
  "feed_url": "https://llbbl.blog/feed.json",
  "items": [
      {
        "id": "http://llbbl.micro.blog/2026/09/06/i-gave-installscript-permission-to.html",
        "title": "I Gave Install-Script Permission to a Package I Don't Have",
        "content_html": "<p>There&rsquo;s exactly one file in one of my wiki projects that&rsquo;s a security control rather than a config file, and I hadn&rsquo;t read it in months.</p>\n<p>pnpm 11 removed <code>onlyBuiltDependencies</code> along with four related settings and replaced all of them with a single <code>allowBuilds</code> map, described in the docs as &ldquo;a map of package matchers to explicitly allow (<code>true</code>) or disallow (<code>false</code>) script execution.&rdquo; A postinstall script is arbitrary code running on your machine at install time with your permissions, so the list of packages allowed to have one is the shortest and highest-consequence list in the repo.</p>\n<p>Mine has six entries. Two <code>true</code>, four <code>false</code>. Here&rsquo;s the <code>true</code> half:</p>\n<div class=\"highlight\"><pre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"><code class=\"language-yaml\" data-lang=\"yaml\"><span style=\"display:flex;\"><span><span style=\"color:#f92672\">allowBuilds</span>:\n</span></span><span style=\"display:flex;\"><span>  <span style=\"color:#f92672\">sharp</span>: <span style=\"color:#66d9ef\">true</span> <span style=\"color:#75715e\"># native libvips bindings (image processing)</span>\n</span></span><span style=\"display:flex;\"><span>  <span style=\"color:#f92672\">&#34;@xenova/transformers&#34;: </span><span style=\"color:#66d9ef\">true</span> <span style=\"color:#75715e\"># native ML runtime</span>\n</span></span></code></pre></div><p>And here&rsquo;s the count:</p>\n<pre tabindex=\"0\"><code>$ grep -c &#39;transformers&#39; pnpm-lock.yaml\n0\n$ grep -rn &#39;xenova&#39; --include=&#39;*.json&#39; --include=&#39;*.yaml&#39; --include=&#39;*.ts&#39; . | grep -v node_modules\npnpm-workspace.yaml:28:  &#34;@xenova/transformers&#34;: true # native ML runtime\n</code></pre><p><strong>Zero</strong> references in the lockfile. <strong>One</strong> reference in the entire repository, and it&rsquo;s the line granting the permission.</p>\n<p>This project used to run embeddings locally. That approach is gone, search goes through a hosted retrieval service now, and the project&rsquo;s own direction notes say not to reintroduce local embeddings. The dependency left. The standing permission to execute native build scripts on my machine stayed behind, waiting for a package that&rsquo;s never going to be installed.</p>\n<h2 id=\"a-dead-grant-isnt-a-vulnerability-its-a-broken-instrument\">A dead grant isn&rsquo;t a vulnerability, it&rsquo;s a broken instrument</h2>\n<p>Nobody&rsquo;s exploiting this. The package isn&rsquo;t in the tree, so nothing runs. I want to be clear about that before anyone gets excited.</p>\n<p>What it costs me is the ability to trust the file. An allowlist works as a control only if every entry means <em>I read this package&rsquo;s install script and I accept it</em>. The moment one entry instead means <em>this was true in a previous version of the project</em>, the list becomes a record of the past, and the next person to open it has no way to tell the two kinds of entry apart without re-deriving all six from the lockfile. The next person is me, in four months, at which point I will absolutely assume the file is current.</p>\n<p>This is the failure mode of every allowlist I&rsquo;ve kept. They only grow. Removing an entry requires noticing that something left, and nothing tells you when a dependency stops existing.</p>\n<h2 id=\"the-other-true-entry-has-the-opposite-problem\">The other <code>true</code> entry has the opposite problem</h2>\n<p><code>sharp</code> is granted a native build. <code>sharp</code> is also not in my <code>package.json</code>. It shows up anyway:</p>\n<pre tabindex=\"0\"><code>$ pnpm why sharp\nsharp@0.35.3\n└─┬ astro@7.2.4\n  ├─┬ @astrojs/node@11.1.4\n  │ └── &lt;package&gt; (dependencies)\n  └── &lt;package&gt; (dependencies)\n</code></pre><p>Astro declares it as an optional dependency. I wrote about that mechanic in a different repo a few days ago, so I&rsquo;ll skip the re-explanation. What&rsquo;s new to me here is that the same workspace file also pins it:</p>\n<div class=\"highlight\"><pre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"><code class=\"language-yaml\" data-lang=\"yaml\"><span style=\"display:flex;\"><span><span style=\"color:#f92672\">overrides</span>:\n</span></span><span style=\"display:flex;\"><span>  <span style=\"color:#f92672\">vite</span>: <span style=\"color:#ae81ff\">^8.2.2</span>\n</span></span><span style=\"display:flex;\"><span>  <span style=\"color:#f92672\">sharp</span>: <span style=\"color:#ae81ff\">^0.35.3</span>\n</span></span></code></pre></div><p>The comment sitting above that block warns that several of these pins are CVE remediations for transitive dependencies, and not to relax a bound without checking the advisory it was added for. That&rsquo;s a good note. It&rsquo;s also attached to a package I never asked for, whose version I control only because I reached into the resolver and overrode somebody else&rsquo;s optional dependency.</p>\n<h2 id=\"the-false-entries-turned-out-to-be-the-good-news\">The <code>false</code> entries turned out to be the good news</h2>\n<div class=\"highlight\"><pre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"><code class=\"language-yaml\" data-lang=\"yaml\"><span style=\"display:flex;\"><span>  <span style=\"color:#f92672\">&#34;@prisma/engines&#34;: </span><span style=\"color:#66d9ef\">false</span>\n</span></span><span style=\"display:flex;\"><span>  <span style=\"color:#f92672\">prisma</span>: <span style=\"color:#66d9ef\">false</span>\n</span></span><span style=\"display:flex;\"><span>  <span style=\"color:#f92672\">better-sqlite3</span>: <span style=\"color:#66d9ef\">false</span>\n</span></span><span style=\"display:flex;\"><span>  <span style=\"color:#f92672\">esbuild</span>: <span style=\"color:#66d9ef\">false</span>\n</span></span></code></pre></div><p>These four aren&rsquo;t a new restriction, and it took me a minute to work out why they&rsquo;re written down at all.</p>\n<p>Under pnpm 10, <code>onlyBuiltDependencies</code> was an <em>exclusive</em> allowlist. Anything not on it got skipped. All four of these were already being skipped, silently, for as long as this project has existed. pnpm 11&rsquo;s <code>strictDepBuilds</code> refuses to skip quietly, and exits non-zero when a dependency has an unreviewed build script.</p>\n<p>So the upgrade changed no behavior at all. It changed whether the behavior was <em>visible</em>, and converted four invisible skips into four decisions I had to write down and sign. That&rsquo;s the good version of a breaking change, and it&rsquo;s the reason the dead <code>@xenova/transformers</code> entry was sitting right there for me to trip over.</p>\n<h2 id=\"one-more-thing-since-i-had-the-shell-open\">One more thing, since I had the shell open</h2>\n<pre tabindex=\"0\"><code>$ grep -c &#39;0.34.5&#39; pnpm-lock.yaml\n0\n$ du -sh node_modules/.pnpm/@img+sharp-libvips-darwin-arm64@1.2.4\n 15M\n</code></pre><p><code>sharp@0.34.5</code> has zero references in the current lockfile and is still on disk, next to <strong>15M</strong> of libvips binaries compiled for it, which in turn sit next to the <strong>17M</strong> of libvips 1.3.2 that the current resolution actually uses. The override moved from <code>^0.34.4</code> to <code>^0.35.3</code> in a dependency sweep last week, and the old native payload never left. That&rsquo;s <code>pnpm store prune</code> territory rather than a correctness bug, but it&rsquo;s 15M of compiled image-processing code on a machine whose project doesn&rsquo;t declare an image-processing dependency.</p>\n<h2 id=\"what-im-changing\">What I&rsquo;m changing</h2>\n<ol>\n<li><strong>Reconcile the allowlist against the lockfile in CI.</strong> Every key in <code>allowBuilds</code> should resolve to something in <code>pnpm-lock.yaml</code>, or the build should complain. It&rsquo;s maybe ten lines of test and I don&rsquo;t have it.</li>\n<li><strong>Say why, next to every entry.</strong> Two of my six have a reason comment. The reason is worth more than the package name, because the reason is the thing that expires.</li>\n<li><strong>Re-read the file whenever an override moves.</strong> The pins and the build grants describe the same dependency graph, and they drifted apart without a word.</li>\n</ol>\n<p>The security value of an allowlist lives in the reviewing, not in the file. I&rsquo;ve been maintaining the file.</p>\n<h2 id=\"sources\">Sources</h2>\n<ul>\n<li><a href=\"https://pnpm.io/settings/build\">pnpm build settings</a> — <code>allowBuilds</code> and <code>strictDepBuilds</code>, plus the migration table from the five settings pnpm 11 removed</li>\n<li><a href=\"https://pnpm.io/blog/releases/11.0\">pnpm 11.0 release notes</a> — the removal of <code>onlyBuiltDependencies</code> and friends</li>\n<li><a href=\"https://pnpm.io/settings\">pnpm settings index</a> — the full <code>pnpm-workspace.yaml</code> surface</li>\n</ul>\n<blockquote>\n<p>I&rsquo;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at <a href=\"https://micro.blog/llbbl?remote_follow=1\">@logan@llbbl.blog</a>.</p>\n</blockquote>\n",
        "date_published": "2026-09-06T10:00:00-05:00",
        "url": "https://llbbl.blog/2026/09/06/i-gave-installscript-permission-to.html",
        "tags": ["security","javascript","Dependencies","Pnpm"]
      }
  ]
}
