<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://nesbitt.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://nesbitt.io/" rel="alternate" type="text/html" /><updated>2026-04-24T10:40:24+00:00</updated><id>https://nesbitt.io/feed.xml</id><title type="html">Andrew Nesbitt</title><subtitle>Package management and open source metadata expert. Building Ecosyste.ms, open datasets and tools for critical open source infrastructure.</subtitle><author><name>Andrew Nesbitt</name><email>andrew@ecosyste.ms</email></author><entry><title type="html">brief</title><link href="https://nesbitt.io/2026/04/21/brief.html" rel="alternate" type="text/html" title="brief" /><published>2026-04-21T10:00:00+00:00</published><updated>2026-04-21T10:00:00+00:00</updated><id>https://nesbitt.io/2026/04/21/brief</id><content type="html" xml:base="https://nesbitt.io/2026/04/21/brief.html"><![CDATA[<p>Anyone landing in an unfamiliar repo, whether that’s a new contributor, a security scanner, or an AI coding agent, has to answer the same handful of questions before doing anything useful: what language is this, how do I install dependencies, what’s the test command, which linter do I run before committing, and for a security review, which functions in this stack are the dangerous ones.</p>

<p>The agent case just makes the cost of getting it wrong easiest to watch, because you can see Claude grep for <code class="language-plaintext highlighter-rouge">package.json</code>, read the Gemfile, try <code class="language-plaintext highlighter-rouge">npm test</code>, get told there’s no test script, try <code class="language-plaintext highlighter-rouge">yarn test</code>, discover it’s actually <code class="language-plaintext highlighter-rouge">pnpm</code>, and only then get to the work you asked for. The answers are identical for every Rails project or every Go module that has ever existed, and rediscovering them from scratch every time is wasted effort.</p>

<p><a href="https://github.com/git-pkgs/brief">brief</a> is a knowledge base of 516 tools across 54 language ecosystems, with a single Go binary in front of it that does the lookup and prints JSON when piped or a human summary on a TTY. The dataset is the part that doesn’t exist anywhere else: invocation commands, config-file locations, and taxonomy for five hundred tools under one machine-readable schema. CI templates, devcontainer generators, and editor onboarding flows were the closest I found, each carrying a slice of it with no shared upstream. I think of the CLI as one view onto that data and expect there to be others.</p>

<p>Point it at a directory, a git URL, or a registry coordinate like <code class="language-plaintext highlighter-rouge">gem:rails</code> or <code class="language-plaintext highlighter-rouge">npm:express</code> and it reports the toolchain across twenty categories, each with the command to run and the config files that drive it, plus whatever governance and community files (license with SPDX identifier, security policy, <code class="language-plaintext highlighter-rouge">CODEOWNERS</code>, <code class="language-plaintext highlighter-rouge">FUNDING.yml</code>, and so on) it finds in the usual places.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>brief <span class="nb">.</span>                       <span class="c"># local directory</span>
brief gem:rails               <span class="c"># registry package, resolved to source repo</span>
brief diff                    <span class="c"># only tools touched by changed files</span>
brief missing                 <span class="c"># baseline categories with no tool configured</span>
brief threat-model            <span class="c"># CWE/OWASP categories implied by the stack</span>
brief sinks                   <span class="c"># dangerous functions in detected tools</span>
</code></pre></div></div>

<p>Checking all 516 definitions finishes in under 250ms, since anything that runs at the front of every session or pipeline step can’t afford to be the slow part; on this blog’s own repo it picks out Jekyll, Bundler, Rake, Dependabot and GitHub Actions in around 220ms, and on a Go project the output looks like:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ brief .
Language:        Go
Package Manager: Go Modules (go mod download)
Test:            go test (go test ./...)
Lint:            golangci-lint (golangci-lint run)  [.golangci.yml]
Format:          gofmt (gofmt -w .)
Build:           GoReleaser (goreleaser release --clean)
Security:        govulncheck (govulncheck ./...)
CI:              GitHub Actions  [.github/workflows/]
</code></pre></div></div>

<p>I run it myself as the first thing after cloning anything, and I have it wired into my global agent instructions so every Claude session opens with <code class="language-plaintext highlighter-rouge">brief .</code> before anything else. That onboards the agent to the repo in one tool call and saves the tokens it would otherwise burn on exploratory greps and wrong guesses. On a feature branch <code class="language-plaintext highlighter-rouge">brief diff</code> narrows the report to just the tools touched by the changed files, so whoever is reading it knows to run <code class="language-plaintext highlighter-rouge">golangci-lint</code> because a <code class="language-plaintext highlighter-rouge">.go</code> file changed without also being told about the Python linter in the monorepo’s other half.</p>

<p>Because the JSON output follows a <a href="https://github.com/git-pkgs/brief#library-usage">published schema</a>, it also works as a building block for other tooling: <code class="language-plaintext highlighter-rouge">brief --json . | jq -r '.tools.test[0].command.run'</code> gives a polyglot CI job the project’s test command without anyone writing per-language cases, that lookup can drive a devcontainer or onboarding script, and the plan is to run it across every repo <a href="https://ecosyste.ms">ecosyste.ms</a> indexes so that stack metadata is available for every package.</p>

<p>The detection rules are TOML rather than Go, which means adding a tool is a single file under <code class="language-plaintext highlighter-rouge">knowledge/</code> with no code changes: a name, a category, the files or dependency names that signal its presence, the command to run it, and optionally a set of <a href="https://github.com/ecosyste-ms/oss-taxonomy">oss-taxonomy</a> tags describing what kind of thing it is. That taxonomy is <a href="/2025/11/29/oss-taxonomy.html">a sibling project</a>: it builds the vocabulary for what a tool <em>is</em>; brief detects which tools a project <em>uses</em>.</p>

<p>The dependency-name matching is driven by the same manifest parser as <a href="https://github.com/git-pkgs/git-pkgs">git-pkgs</a>, so a tool definition can say “present if <code class="language-plaintext highlighter-rouge">rspec-core</code> is in the bundle” and brief already knows how to read Gemfiles, <code class="language-plaintext highlighter-rouge">package.json</code>, <code class="language-plaintext highlighter-rouge">go.mod</code>, <code class="language-plaintext highlighter-rouge">Cargo.toml</code>, and the other supported lockfile formats without any of that being reimplemented.</p>

<p>Those tags were originally there so the JSON output could say “web framework” rather than just “build tool”, but once a few hundred definitions carried them they mapped cleanly onto CWE and OWASP categories, and <code class="language-plaintext highlighter-rouge">brief threat-model</code> on a Rails project produces SQL injection, mass assignment, XSS, CSRF, and SSTI without scanning a line of code, because that’s what Rails and ActiveRecord are <em>for</em>. The definitions also carry the specific dangerous functions each tool exposes, around 700 across the dataset, which is a reasonable starting grep list for a security review of a stack you’ve never worked in:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ brief sinks .
ActiveRecord:
  Arel.sql            sql_injection      CWE-89
  find_by_sql         sql_injection      CWE-89
  where               sql_injection      CWE-89   string interpolation only
Rails:
  html_safe           xss                CWE-79
  redirect_to         open_redirect      CWE-601  when target is from params
  render inline:      ssti               CWE-1336
Ruby:
  eval                code_injection     CWE-95
  Marshal.load        deserialization    CWE-502
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">brief missing</code> inverts the check and reports which of five baseline categories (test, lint, format, typecheck, docs) have no tool configured for the detected ecosystems, naming the canonical choice for each gap. The detection engine is also importable as a Go library if you’d rather not shell out.</p>

<p>Tool definitions live in <a href="https://github.com/git-pkgs/brief/tree/main/knowledge">the <code class="language-plaintext highlighter-rouge">knowledge/</code> directory</a> and PRs adding new ones are the contributions I’m most interested in, particularly for ecosystems I don’t write every day. If you point it at a project and it gets something wrong, <a href="https://github.com/git-pkgs/brief/issues">open an issue</a> or find me on <a href="https://mastodon.social/@andrewnez">Mastodon</a>.</p>

<p><code class="language-plaintext highlighter-rouge">brew install git-pkgs/git-pkgs/brief</code> / <a href="https://github.com/git-pkgs/brief">github.com/git-pkgs/brief</a></p>]]></content><author><name>Andrew Nesbitt</name><email>andrew@ecosyste.ms</email></author><category term="open-source" /><category term="tools" /><category term="git-pkgs" /><category term="ai" /><category term="security" /><summary type="html"><![CDATA[A knowledge base of project conventions, exposed as a CLI.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nesbitt.io/images/boxes.png" /><media:content medium="image" url="https://nesbitt.io/images/boxes.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Features everyone should steal from npmx</title><link href="https://nesbitt.io/2026/04/16/features-everyone-should-steal-from-npmx.html" rel="alternate" type="text/html" title="Features everyone should steal from npmx" /><published>2026-04-16T10:00:00+00:00</published><updated>2026-04-16T10:00:00+00:00</updated><id>https://nesbitt.io/2026/04/16/features-everyone-should-steal-from-npmx</id><content type="html" xml:base="https://nesbitt.io/2026/04/16/features-everyone-should-steal-from-npmx.html"><![CDATA[<p>For most of the time GitHub has owned npm, the public-facing website at npmjs.com has been effectively frozen, with the issue tracker accumulating years of requests that nobody on the inside seemed to be reading. In January <a href="https://roe.dev">Daniel Roe</a> started <a href="https://npmx.dev">npmx.dev</a> as an alternative web frontend over the same registry data, posted about it on Bluesky, and within a fortnight years of pent-up demand had turned into a thousand issues and pull requests on a repo that would actually merge them, with the contributor count passing a hundred a couple of days after that. It helps that every npmjs.com URL works with the hostname swapped to <code class="language-plaintext highlighter-rouge">npmx.dev</code> or <code class="language-plaintext highlighter-rouge">xnpmjs.com</code>, the same trick Invidious and Nitter used, so browser extensions and muscle memory carry straight over. The competitive pressure appears to have worked: npmjs.com shipped dark mode last month, the single most upvoted feature request on the tracker for something like five years, and there are signs of other long-dormant tickets being picked up.</p>

<p>Whether or not that continues, npmx has turned into a useful catalogue of ideas for anyone building a package registry website, and the <a href="https://github.com/npmx-dev/npmx.dev">whole thing is MIT licensed</a> where the npm registry and website remain closed source, so every feature below comes with a working reference implementation rather than just screenshots. Prior art from other ecosystems is noted where it exists.</p>

<ul>
  <li>
    <p><strong>Transitive install size.</strong> The number shown is the unpacked size of the package plus every dependency it pulls in, which is what actually lands on disk, rather than the single tarball size that crates.io and PyPI show. JavaScript developers have been getting this from <a href="https://bundlephobia.com">bundlephobia</a> and <a href="https://packagephobia.com">packagephobia</a> for years.</p>
  </li>
  <li>
    <p><strong>Install script disclosure.</strong> Any <code class="language-plaintext highlighter-rouge">preinstall</code>, <code class="language-plaintext highlighter-rouge">install</code>, or <code class="language-plaintext highlighter-rouge">postinstall</code> script in the manifest is rendered on the package page along with the <code class="language-plaintext highlighter-rouge">npx</code> packages those scripts would fetch, with links into the code browser so you can read what runs. Worth having in front of you given how many supply-chain incidents start with a postinstall hook.</p>
  </li>
  <li>
    <p><strong>Outdated and vulnerable dependency trees.</strong> Rather than a flat list of declared dependencies, you get an expandable tree where each node is annotated with how far behind latest it is and whether it appears in <a href="https://osv.dev">OSV</a>, recursively through transitives. Google’s <a href="https://deps.dev">deps.dev</a> does something similar across ecosystems.</p>
  </li>
  <li>
    <p><strong>Version range resolution.</strong> Wherever a semver range like <code class="language-plaintext highlighter-rouge">^1.0.0</code> appears it is shown alongside the concrete version it currently resolves to, which saves a round trip to the CLI when you are trying to work out what you would actually get.</p>
  </li>
  <li>
    <p><strong>Module replacement suggestions.</strong> Packages that appear in the <a href="https://github.com/es-tooling/module-replacements">e18e module-replacements dataset</a> get a banner pointing at the native API or lighter alternative, with MDN links for the native cases.</p>
  </li>
  <li>
    <p><strong>Module format and types badges.</strong> ESM, CJS, or dual is shown next to the package name, as is whether TypeScript types are bundled or need a separate <code class="language-plaintext highlighter-rouge">@types/*</code> install, plus the declared Node engine range. JavaScript-specific in the details but the general idea of “will this work with my toolchain” badges travels; crates.io’s MSRV field and edition badge are in the same spirit.</p>
  </li>
  <li>
    <p><strong>Multi-forge repository stats.</strong> Star and fork counts are fetched from <a href="https://docs.npmx.dev/guide/features#supported-git-providers">GitHub, GitLab, Bitbucket, Codeberg, Gitee, Sourcehut, Forgejo, Gitea, Radicle, and Tangled</a>, depending on where the <code class="language-plaintext highlighter-rouge">repository</code> field points, rather than special-casing GitHub.</p>
  </li>
  <li>
    <p><strong>Cross-registry availability.</strong> Scoped packages that also exist on <a href="https://jsr.io">JSR</a> are flagged as such. The npm/JSR pairing is particular to JavaScript but “this is also on registry X” applies anywhere ecosystems overlap, like Maven and Clojars or the various Linux distro repos, and the same lookup doubles as a dependency-confusion check when the name exists elsewhere but the publisher does not match.</p>
  </li>
  <li>
    <p><strong>Side-by-side package comparison.</strong> Up to ten packages can be loaded into a <a href="https://npmx.dev/compare">compare view</a> that lays out all the metrics above in a table, plus a scatter plot with aggregate “traction” on one axis and “ergonomics” on the other so the popular-but-heavy and small-but-unknown options separate visually.</p>
  </li>
  <li>
    <p><strong>Version diffing.</strong> Any two published versions can be diffed file-by-file in the browser, which Hex has had for years via <a href="https://diff.hex.pm">diff.hex.pm</a> and which exists in the Rust world through <code class="language-plaintext highlighter-rouge">cargo-vet</code> tooling.</p>
  </li>
  <li>
    <p><strong>Release timeline with size annotations.</strong> Every version of a package is plotted on a timeline with markers where install size jumped by a meaningful percentage, which is a neat way to spot the release where someone accidentally started shipping their test fixtures.</p>
  </li>
  <li>
    <p><strong>Download distribution by version.</strong> The weekly download chart can be broken down by major or minor line so you can see how much of the ecosystem is still on v2 of something now on v5, similar to <a href="https://rubygems.org/gems/rails/versions">RubyGems’ per-version download counts</a> but rendered as a distribution rather than a table.</p>
  </li>
  <li>
    <p><strong>Command palette.</strong> <code class="language-plaintext highlighter-rouge">⌘K</code> opens a palette with every action available on the current page plus global navigation, and on a package page typing a semver range filters the version list to matches. Borrowed from editors and from GitHub itself rather than from any registry.</p>
  </li>
  <li>
    <p><strong>Internationalisation.</strong> The interface ships in <a href="https://github.com/npmx-dev/npmx.dev/tree/main/i18n/locales">over thirty locales</a> including RTL languages, with <a href="https://hosted.weblate.org/projects/pypa/warehouse/">PyPI’s Warehouse</a> being the other registry that has invested in this.</p>
  </li>
  <li>
    <p><strong>Accessibility as a default.</strong> Charts and demo videos in the release notes carry long-form <code class="language-plaintext highlighter-rouge">aria-label</code> and <code class="language-plaintext highlighter-rouge">figcaption</code> text, the command palette works with screen readers, and there is a dedicated <a href="https://npmx.dev/accessibility">accessibility statement</a>.</p>
  </li>
  <li>
    <p><strong>Playground link extraction.</strong> StackBlitz, CodeSandbox, CodePen, JSFiddle, and Replit links found in a package’s README are pulled out into a dedicated panel so you can try the thing without cloning it.</p>
  </li>
  <li>
    <p><strong>Agent skill detection.</strong> Packages that contain <a href="https://www.anthropic.com/news/skills">Agent Skills</a> manifests have them listed with declared tool compatibility, which is very 2026, though detecting non-code payloads in published packages is useful.</p>
  </li>
  <li>
    <p><strong>Social features on AT Protocol.</strong> Package “likes” are <a href="https://atproto.com">atproto</a> records rather than rows in a private database, blog comment threads are Bluesky threads, and the custom record types are <a href="https://github.com/npmx-dev/npmx.dev/tree/main/lexicons">public lexicons in the repo</a> so other tools can read and write the same data without talking to npmx. If you have ever wanted to add reviews or comments to a registry and balked at the moderation burden of running another silo, borrowing an existing network’s identity and content layer is a defensible answer, and while I am personally sceptical that leaning on Bluesky’s infrastructure will work out long term, npmx at least runs <a href="https://npmx.dev/pds">its own PDS at npmx.social</a> so the records stay under their control either way.</p>
  </li>
  <li>
    <p><strong>Local-CLI admin connector.</strong> Management actions like claiming a package name or editing access are proxied through your local <code class="language-plaintext highlighter-rouge">npm</code> CLI rather than requiring you to log into the site, which sidesteps the need for npmx to hold credentials for a registry it does not own.</p>
  </li>
  <li>
    <p><strong>Dark mode and custom palettes.</strong> Listed last because this is the one npm has now copied, joining pkg.go.dev, crates.io, and PyPI which already had it.</p>
  </li>
</ul>

<hr />

<p>Someone in the .NET world has already built an equivalent: <a href="https://nugx.org/">nugx.org</a>, in its own words “inspired by npmx”, is doing the same thing for NuGet.</p>]]></content><author><name>Andrew Nesbitt</name><email>andrew@ecosyste.ms</email></author><category term="package-managers" /><category term="npm" /><summary type="html"><![CDATA[What happens when users design their own package registry frontend]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nesbitt.io/images/boxes.png" /><media:content medium="image" url="https://nesbitt.io/images/boxes.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">The Tuesday Test</title><link href="https://nesbitt.io/2026/04/15/the-tuesday-test.html" rel="alternate" type="text/html" title="The Tuesday Test" /><published>2026-04-15T10:00:00+00:00</published><updated>2026-04-15T10:00:00+00:00</updated><id>https://nesbitt.io/2026/04/15/the-tuesday-test</id><content type="html" xml:base="https://nesbitt.io/2026/04/15/the-tuesday-test.html"><![CDATA[<p><a href="/2026/04/14/standing-on-the-shoulders-of-homebrew.html">Yesterday</a> I wrote about the fast Homebrew rewrites and ended on the line that the bottleneck for that whole class of project is not Rust or Ruby, it is the absence of a stable declarative package schema. Someone on Mastodon picked up that thread and asked the obvious follow-on: which package managers actually have one? Going through the list, the honest answer is hardly any of them, and there is a quick test that makes the answer easy to check.</p>

<p>Ask this of any package manager: if I install this package on a Tuesday, could it do something different than if I install it on a Wednesday? If the answer is yes, the package manager is not really declarative, no matter what the manifest file looks like on the surface.</p>

<p>Somewhere in the install pipeline there is a place where arbitrary code runs, and that code can read the clock, check an environment variable, look at the hostname, phone a server, or do anything else a program can do. The Tuesday test is a quick way to separate the declarative tools from the ones that have a programming language hiding underneath a declarative-looking file format.</p>

<p>The test is not about whether the code is malicious, or whether it is a supply chain risk, or whether it could in principle do something terrible. Those are all separate questions with their own answers.</p>

<p>It is also not about the registry changing under you between the two days: new versions, yanks and the like are all real concerns, but they are concerns about the data the package manager is fetching rather than about the package manager itself. Pretend the registry is frozen and the lockfile is pinned.</p>

<p>The question here is narrower. Given the same manifest, the same lockfile and the same registry contents, is the install allowed to read anything the manifest does not declare as an input? The day of the week is the simplest example of such a hidden input, but the real point is that a package that passes the Tuesday test has no way to reach outside its declared inputs at all. A package that fails it can, and once it can, the manifest is no longer the whole story. Going through the list of well known package managers from <a href="/2026/01/03/the-package-management-landscape.html">the landscape post</a>, it turns out that almost none of them pass.</p>

<h3 id="homebrew">Homebrew</h3>

<p>Start with the one that started this. A <a href="https://docs.brew.sh/Formula-Cookbook">Homebrew formula</a> is a Ruby class with an <code class="language-plaintext highlighter-rouge">install</code> method and a <code class="language-plaintext highlighter-rouge">post_install</code> hook, and the entire class body is evaluated by the Homebrew client every time it touches the formula. Even the parts that look like data, such as <code class="language-plaintext highlighter-rouge">url</code>, <code class="language-plaintext highlighter-rouge">sha256</code>, <code class="language-plaintext highlighter-rouge">version</code>, and <code class="language-plaintext highlighter-rouge">depends_on</code>, are method calls on the formula class, evaluated in a Ruby context that can <code class="language-plaintext highlighter-rouge">require</code> anything, shell out to anything, and read the clock from any method.</p>

<p>The <a href="https://docs.brew.sh/Cask-Cookbook">cask format</a> is a Ruby DSL with the same property. So is the <a href="https://docs.brew.sh/Manpage#bundle-subcommand"><code class="language-plaintext highlighter-rouge">Brewfile</code></a> consumed by <code class="language-plaintext highlighter-rouge">brew bundle</code>, which was invented as a Homebrew analogue of Bundler’s <code class="language-plaintext highlighter-rouge">Gemfile</code> and inherits the same “executable Ruby file posing as a manifest” shape: a Brewfile can call <code class="language-plaintext highlighter-rouge">brew</code>, <code class="language-plaintext highlighter-rouge">cask</code>, <code class="language-plaintext highlighter-rouge">tap</code>, <code class="language-plaintext highlighter-rouge">mas</code>, <code class="language-plaintext highlighter-rouge">vscode</code> and friends, but it can also <code class="language-plaintext highlighter-rouge">if Time.now.wday == 2</code> in between.</p>

<p>Homebrew fails the Tuesday test by design, which is the whole reason the <a href="https://formulae.brew.sh/api/formula.json"><code class="language-plaintext highlighter-rouge">formula.json</code></a> API had to exist as a separate thing for fast clients to consume: there is no other way to extract package metadata without running the package definition. The JSON file is what passes the Tuesday test, and it only exists because the formula format does not.</p>

<p>Generating it is not free either. Homebrew’s own <a href="https://github.com/Homebrew/brew/blob/master/Library/Homebrew/dev-cmd/generate-formula-api.rb"><code class="language-plaintext highlighter-rouge">brew generate-formula-api</code></a> command has to flip the <code class="language-plaintext highlighter-rouge">Formula</code> class into a special <code class="language-plaintext highlighter-rouge">generating_hash!</code> mode and wrap the run in a <a href="https://github.com/Homebrew/brew/blob/master/Library/Homebrew/simulate_system.rb"><code class="language-plaintext highlighter-rouge">SimulateSystem</code></a> block that lies to every formula about the host OS and architecture, so that calls which would normally branch on the real system instead return a stable answer. It is in-process monkey patching to stop the formula from noticing where it is, in order to coax a declarative-looking file out of a format that is anything but.</p>

<h3 id="ruby">Ruby</h3>

<p>The <a href="https://bundler.io/man/gemfile.5.html"><code class="language-plaintext highlighter-rouge">Gemfile</code></a> is a Ruby file. The first line is often <code class="language-plaintext highlighter-rouge">source "https://rubygems.org"</code>, which looks like configuration, but <code class="language-plaintext highlighter-rouge">source</code> is a method call on an implicit DSL object, and anything else you can write in Ruby is valid above, below, or inside it. You can open a socket in your Gemfile. You can check <code class="language-plaintext highlighter-rouge">Time.now.wday</code> and add a different gem on Tuesdays.</p>

<p>The <a href="https://guides.rubygems.org/specification-reference/"><code class="language-plaintext highlighter-rouge">.gemspec</code></a> file that ships inside every gem is also Ruby, and it is evaluated every time someone installs the gem, which means a gem author can put arbitrary code in the specification itself and have it run on the installer’s machine before anything has been built. Native extensions run <a href="https://guides.rubygems.org/gems-with-extensions/"><code class="language-plaintext highlighter-rouge">extconf.rb</code></a>, which is yet more Ruby, and post-install messages are generated at install time.</p>

<p>CocoaPods is the same story in a different namespace. The CocoaPods client is itself a Ruby program, a <a href="https://guides.cocoapods.org/syntax/podfile.html"><code class="language-plaintext highlighter-rouge">Podfile</code></a> is a direct descendant of a <code class="language-plaintext highlighter-rouge">Gemfile</code>, and a <a href="https://guides.cocoapods.org/syntax/podspec.html"><code class="language-plaintext highlighter-rouge">.podspec</code></a> is a direct descendant of a <code class="language-plaintext highlighter-rouge">.gemspec</code>, right down to the DSL, the block syntax, and the fact that both files are evaluated as Ruby every time you install. Everything said about Ruby above applies to CocoaPods without a single change.</p>

<h3 id="python">Python</h3>

<p>Python is the same story with different file names. A <a href="https://setuptools.pypa.io/en/latest/userguide/index.html"><code class="language-plaintext highlighter-rouge">setup.py</code></a> is a Python script that runs at install time, and <code class="language-plaintext highlighter-rouge">setup.py</code> is where Python packaging started, so an enormous amount of the existing ecosystem still goes through it.</p>

<p>The move to <a href="https://packaging.python.org/en/latest/specifications/pyproject-toml/"><code class="language-plaintext highlighter-rouge">pyproject.toml</code></a> looks like a shift to a declarative manifest, and in the limited sense that the file itself is TOML it is, but the whole job of that TOML file is to nominate a program to run. The <code class="language-plaintext highlighter-rouge">[build-system]</code> table points at a <a href="https://peps.python.org/pep-0517/">build backend</a>, and the build backend is a Python package that executes arbitrary Python to produce a wheel. <a href="https://setuptools.pypa.io/">Setuptools</a>, <a href="https://hatch.pypa.io/latest/">Hatchling</a>, <a href="https://python-poetry.org/docs/pyproject/">Poetry-core</a>, <a href="https://backend.pdm-project.org/">PDM-backend</a>, <a href="https://flit.pypa.io/en/stable/">Flit</a>, <a href="https://www.maturin.rs/">Maturin</a> and <a href="https://scikit-build-core.readthedocs.io/">scikit-build-core</a> are all real programs, all capable of reading the date.</p>

<p>Wheels themselves are the one part of the Python pipeline that does pass the test: <a href="https://peps.python.org/pep-0427/">PEP 427</a> deliberately has no pre or post install hooks, and installing a wheel is meant to be a pure file-unpacking step. If a wheel does not already exist for your platform, pip and uv and Poetry and pdm will transparently build one from the sdist by invoking the build backend, which puts you back in arbitrary-Python territory.</p>

<h3 id="javascript">JavaScript</h3>

<p>JavaScript is the canonical example people reach for, because <code class="language-plaintext highlighter-rouge">package.json</code> is famously JSON, which is as declarative a format as you can get, and yet npm install runs arbitrary code through the <code class="language-plaintext highlighter-rouge">preinstall</code>, <code class="language-plaintext highlighter-rouge">install</code>, and <code class="language-plaintext highlighter-rouge">postinstall</code> <a href="https://docs.npmjs.com/cli/v10/using-npm/scripts">lifecycle scripts</a>. Those scripts are shell commands that run in the package directory, and nothing stops them from checking <code class="language-plaintext highlighter-rouge">date +%u</code> and branching on the result.</p>

<p>Yarn, pnpm, and Bun all inherit the same lifecycle script contract for compatibility with the existing ecosystem, though recent <a href="https://pnpm.io/settings">pnpm</a> and <a href="https://bun.com/docs/cli/install">Bun</a> releases have started refusing to run scripts for dependencies that are not on an explicit allowlist. The contract is still there, the defaults have just got more cautious.</p>

<h3 id="deno-">Deno 🌮</h3>

<p><a href="https://deno.com/">Deno</a> fetches and caches modules on demand, either at import time or up front with <code class="language-plaintext highlighter-rouge">deno install</code>, and no code the package author supplies runs against the installer’s machine before the module itself is imported. Deno 2 added first-class <code class="language-plaintext highlighter-rouge">package.json</code> and <code class="language-plaintext highlighter-rouge">node_modules</code> support on top of the existing <code class="language-plaintext highlighter-rouge">npm:</code> specifiers, but even then <a href="https://docs.deno.com/runtime/reference/cli/install/">it refuses to run the npm lifecycle scripts</a> by default and requires an explicit <code class="language-plaintext highlighter-rouge">--allow-scripts=&lt;pkg&gt;</code> opt-in for any package that wants them.</p>

<h3 id="rust">Rust</h3>

<p>Rust looks declarative at a glance. <code class="language-plaintext highlighter-rouge">Cargo.toml</code> is TOML, Cargo resolves everything from the lockfile, and the whole ecosystem leans heavily on the idea that a crate is a well defined thing.</p>

<p>Then you notice <a href="https://doc.rust-lang.org/cargo/reference/build-scripts.html"><code class="language-plaintext highlighter-rouge">build.rs</code></a>, which is a Rust file that Cargo compiles and runs before building the crate proper, so it can generate source code, link against system libraries, probe the host, and, yes, check the date. <a href="https://doc.rust-lang.org/reference/procedural-macros.html">Procedural macros</a> are the same story from a different angle: they are Rust code that runs at compile time in the compiler’s own process, and they can do anything a Rust program can do. Both mechanisms are considered normal and widely used.</p>

<h3 id="go-">Go 🌮</h3>

<p><a href="https://go.dev/ref/mod">Go modules</a> come closer to passing than almost anything else in this list. The <code class="language-plaintext highlighter-rouge">go.mod</code> file is a small declarative format with no scripting in it, <code class="language-plaintext highlighter-rouge">go get</code> does not run post-install hooks, and the module proxy and checksum database make the fetch step reproducible and auditable in a way that most other ecosystems are not.</p>

<p>The escape hatch is <a href="https://pkg.go.dev/cmd/cgo">cgo</a>, which invokes the system C compiler with arguments specified by <code class="language-plaintext highlighter-rouge">#cgo</code> directives in source files, and those directives can include whatever paths and flags the package author wants. The core dependency resolution and fetching pipeline is declarative. The build pipeline is not, as soon as C is involved.</p>

<h3 id="jvm-languages">JVM languages</h3>

<p>The JVM ecosystem is split between the declarative-looking and the openly imperative. Maven’s <a href="https://maven.apache.org/pom.html"><code class="language-plaintext highlighter-rouge">pom.xml</code></a> is XML and describes the project as data, but a pom can include plugin executions, and Maven plugins are Java code that runs during the build.</p>

<p><a href="https://docs.gradle.org/current/userguide/userguide.html">Gradle</a> does not even pretend: <code class="language-plaintext highlighter-rouge">build.gradle</code> is a Groovy script, and <code class="language-plaintext highlighter-rouge">build.gradle.kts</code> is a Kotlin script, and both are full programming languages with access to the filesystem, the network, and the clock. <a href="https://www.scala-sbt.org/">sbt</a>’s build definition is Scala. <a href="https://leiningen.org/">Leiningen</a>’s <code class="language-plaintext highlighter-rouge">project.clj</code> is Clojure. <a href="https://mill-build.org/">Mill</a> is Scala again.</p>

<p>The JVM world has spent twenty years treating the build file as a program, and the package management step is a side effect of running that program.</p>

<h3 id="swift">Swift</h3>

<p>Swift Package Manager is in the same category. <a href="https://developer.apple.com/documentation/packagedescription"><code class="language-plaintext highlighter-rouge">Package.swift</code></a> is a Swift file that is compiled and run to produce the package description, which means every resolve of a Swift package involves executing Swift code from the package author. Apple added a manifest API version comment at the top of the file so that the compiler knows which stable API to expose, but the underlying mechanism is still “run the author’s Swift program.”</p>

<h3 id="zig">Zig</h3>

<p>Zig is worth pulling out because it is a modern language that looked at all of the above and decided, deliberately, that the build file should be a real program. <a href="https://ziglang.org/learn/build-system/"><code class="language-plaintext highlighter-rouge">build.zig</code></a> is Zig source compiled and run by the Zig toolchain, and the package manager is a set of APIs exposed to that program. The rationale is that builds in C-adjacent languages are already programs in disguise (makefiles, shell, CMake), and making the language of the build the same as the language of the project is more honest than pretending otherwise. It is a defensible position, and it fails the test completely.</p>

<h3 id="bazel-">Bazel 🌮</h3>

<p>Bazel is the one entry on this list that tries to pass the Tuesday test at the language design level. <code class="language-plaintext highlighter-rouge">BUILD</code> files and <code class="language-plaintext highlighter-rouge">.bzl</code> extensions are written in <a href="https://bazel.build/rules/language">Starlark</a>, a dialect of Python that Google stripped back on purpose: no <code class="language-plaintext highlighter-rouge">while</code> loops, no recursion, no mutable global state, no way to read the clock, the filesystem outside declared inputs, or the network. Evaluation is guaranteed to terminate, and two evaluations of the same inputs are guaranteed to produce the same output. It is the only manifest language on this page that cannot observe what day it is even if the author wants it to.</p>

<p>The execution side is hedged the same way. Actions run inside a sandbox with only their declared inputs visible, and Bazel’s remote execution and remote cache assume that identical inputs produce identical outputs, so any non-determinism shows up as a cache miss and gets investigated.</p>

<p>The usual escape hatches are still there if you want them: <code class="language-plaintext highlighter-rouge">repository_rule</code> can call out to the host to fetch code, <code class="language-plaintext highlighter-rouge">genrule</code> runs shell, and custom toolchains can shell out to anything the sandbox allows, so a sufficiently motivated <code class="language-plaintext highlighter-rouge">BUILD</code> author can still reach the system <code class="language-plaintext highlighter-rouge">date</code> command. But the default posture is the opposite of everywhere else on this list, and the design is organised around passing the Tuesday test as an explicit goal.</p>

<h3 id="haskell">Haskell</h3>

<p>A Haskell package is described by a <code class="language-plaintext highlighter-rouge">.cabal</code> file, which is a custom declarative format, not Haskell source, so the metadata layer on its own passes the Tuesday test. Tools can parse a <code class="language-plaintext highlighter-rouge">.cabal</code> file and extract dependencies, versions and compiler flags without running any of the package author’s code.</p>

<p>The escape hatch is the <code class="language-plaintext highlighter-rouge">build-type</code> field. <code class="language-plaintext highlighter-rouge">build-type: Simple</code> uses a stock Setup script and is fine. <code class="language-plaintext highlighter-rouge">build-type: Custom</code> (and the newer <code class="language-plaintext highlighter-rouge">Hooks</code>) tells Cabal to compile and run the package’s own <a href="https://cabal.readthedocs.io/en/stable/cabal-package.html"><code class="language-plaintext highlighter-rouge">Setup.hs</code></a>, which is a real Haskell program with <code class="language-plaintext highlighter-rouge">preBuild</code>, <code class="language-plaintext highlighter-rouge">postBuild</code>, <code class="language-plaintext highlighter-rouge">preInst</code> and <code class="language-plaintext highlighter-rouge">postInst</code> hooks that can do anything Haskell can do, including read the clock.</p>

<p>Because <code class="language-plaintext highlighter-rouge">.cabal</code> is declarative metadata, it can also be mechanically translated into something else, which is a large part of why Haskell has such a big footprint in the Nix ecosystem. <a href="https://github.com/NixOS/cabal2nix"><code class="language-plaintext highlighter-rouge">cabal2nix</code></a> reads a <code class="language-plaintext highlighter-rouge">.cabal</code> file and emits a Nix expression, Nixpkgs ships a Haskell package set regenerated from Hackage and Stackage through that pipeline, and <a href="https://input-output-hk.github.io/haskell.nix/"><code class="language-plaintext highlighter-rouge">haskell.nix</code></a> is an alternative infrastructure built around the same idea.</p>

<h3 id="everything-else-with-a-manifest-thats-a-program">Everything else with a manifest that’s a program</h3>

<p>The rest of the list is short because the pattern is by now predictable.</p>

<ul>
  <li><strong>PHP / Composer:</strong> <code class="language-plaintext highlighter-rouge">composer.json</code> is JSON, but a <a href="https://getcomposer.org/doc/articles/scripts.md"><code class="language-plaintext highlighter-rouge">scripts</code> section</a> hooks events like <code class="language-plaintext highlighter-rouge">post-install-cmd</code> and <code class="language-plaintext highlighter-rouge">post-update-cmd</code> with shell commands or PHP callables.</li>
  <li><strong>Elixir / Mix:</strong> <a href="https://hexdocs.pm/mix/Mix.html"><code class="language-plaintext highlighter-rouge">mix.exs</code></a> is Elixir.</li>
  <li><strong>Dart / pub:</strong> <code class="language-plaintext highlighter-rouge">pubspec.yaml</code> is declarative, but pub supports <a href="https://dart.dev/tools/pub/hooks">hook scripts</a> for native and data assets, written in Dart and run at build time.</li>
  <li><strong>Perl / CPAN:</strong> <a href="https://metacpan.org/pod/ExtUtils::MakeMaker"><code class="language-plaintext highlighter-rouge">Makefile.PL</code></a> and <a href="https://metacpan.org/pod/Module::Build"><code class="language-plaintext highlighter-rouge">Build.PL</code></a> are Perl programs, and have been since the nineties.</li>
  <li><strong>Lua / LuaRocks:</strong> <a href="https://github.com/luarocks/luarocks/wiki/Rockspec-format">rockspecs</a> are Lua tables, but the build section can include a <code class="language-plaintext highlighter-rouge">build_command</code> that runs shell.</li>
  <li><strong>Nim / Nimble:</strong> <a href="https://github.com/nim-lang/nimble#nimble-reference">nimble files</a> support <code class="language-plaintext highlighter-rouge">before install</code> and <code class="language-plaintext highlighter-rouge">after install</code> hooks written in NimScript.</li>
  <li><strong>Julia / Pkg:</strong> packages run <a href="https://pkgdocs.julialang.org/v1/creating-packages/"><code class="language-plaintext highlighter-rouge">deps/build.jl</code></a> at install time, which is a Julia program.</li>
  <li><strong>Raku / zef:</strong> runs Perl or Raku build scripts.</li>
</ul>

<h3 id="opam-and-portage">opam and Portage</h3>

<p>OCaml’s <a href="https://opam.ocaml.org/doc/Manual.html#opam">opam</a> is unusually honest: the opam file is a declarative-looking S-expression format, but the <code class="language-plaintext highlighter-rouge">build</code> and <code class="language-plaintext highlighter-rouge">install</code> fields contain explicit lists of shell commands to run, and everyone knows what they are and where they live. The same is true, in a different flavour, of Gentoo’s Portage: an <a href="https://devmanual.gentoo.org/ebuild-writing/">ebuild</a> is a bash script that sources a set of library functions and defines phases like <code class="language-plaintext highlighter-rouge">src_compile</code> and <code class="language-plaintext highlighter-rouge">src_install</code>, so the package is a program and no one pretends otherwise.</p>

<h3 id="system-package-managers">System package managers</h3>

<p>System package managers all fail, and most of them fail in several places at once. Debian packages carry <a href="https://www.debian.org/doc/debian-policy/ch-maintainerscripts.html"><code class="language-plaintext highlighter-rouge">preinst</code>, <code class="language-plaintext highlighter-rouge">postinst</code>, <code class="language-plaintext highlighter-rouge">prerm</code>, and <code class="language-plaintext highlighter-rouge">postrm</code> maintainer scripts</a> that dpkg runs around the unpack step, and they are shell by default. RPM packages embed <a href="https://rpm-software-management.github.io/rpm/manual/spec.html"><code class="language-plaintext highlighter-rouge">%pre</code>, <code class="language-plaintext highlighter-rouge">%post</code>, <code class="language-plaintext highlighter-rouge">%preun</code>, and <code class="language-plaintext highlighter-rouge">%postun</code> scriptlets</a>, plus file triggers, which are shell scripts.</p>

<p>Arch’s pacman runs <code class="language-plaintext highlighter-rouge">.INSTALL</code> scripts from inside the package tarball, which are again shell, and <a href="https://wiki.archlinux.org/title/PKGBUILD">PKGBUILDs</a> themselves are shell programs evaluated at build time. Alpine’s apk has pre and post install scripts, plus <a href="https://wiki.alpinelinux.org/wiki/APKBUILD_Reference">APKBUILDs</a> that are shell scripts.</p>

<p><a href="https://guide.macports.org/chunked/reference.html">MacPorts Portfiles</a> are Tcl. <a href="https://docs.chocolatey.org/en-us/create/create-packages">Chocolatey packages</a> are PowerShell. Conda belongs on this list too, even though it is often filed next to Python: it is a cross-language binary package manager that happens to have grown up in the scientific Python community, and it ships explicit <a href="https://docs.conda.io/projects/conda-build/en/latest/resources/link-scripts.html"><code class="language-plaintext highlighter-rouge">pre-link</code> and <code class="language-plaintext highlighter-rouge">post-link</code></a> shell scripts that run when a package is linked into an environment.</p>

<p>Every one of these can look at the clock and do one thing on Monday and a different thing on Tuesday without bending any rules, and Homebrew at the top of the post is the same shape as all of them.</p>

<h3 id="nix-and-guix-">Nix and Guix 🌮</h3>

<p><a href="https://nixos.org/">Nix</a> is the interesting case, because it is the one package manager on the list that has been designed from the start around the idea that the install step should not be allowed to notice what day it is. A Nix expression is a program in the Nix language, but it is a pure lazy functional language with no I/O primitives of the sort you would need to read a clock, so the evaluation step that produces a derivation cannot observe the day of the week at all.</p>

<p>The derivation is then realised by running a builder inside a sandbox that has no network, a scrubbed environment, and its own view of the filesystem. The sandbox itself does not pin the clock, so a determined builder can still call <code class="language-plaintext highlighter-rouge">date</code> and get a real answer. In practice Nixpkgs and the wider <a href="https://reproducible-builds.org/">reproducible-builds.org</a> project paper over this with <a href="https://reproducible-builds.org/docs/source-date-epoch/"><code class="language-plaintext highlighter-rouge">SOURCE_DATE_EPOCH</code></a>, an environment variable that well-behaved build tools read instead of the real clock when stamping timestamps into their output, often set to the Unix epoch or the commit time of the source. The Tuesday test passes cleanly at the evaluation layer and passes in most cases at the realisation layer, with the remaining gaps treated as bugs rather than features.</p>

<p><a href="https://guix.gnu.org/">Guix</a> tells the same story with different syntax. The package definitions are written in Guile Scheme, which is a full language in the way that the Nix language deliberately is not, but package records are a restricted form and the build is run inside the same kind of sandbox, inherited from the same <a href="https://edolstra.github.io/pubs/phd-thesis.pdf">derivation model</a> that Eelco Dolstra wrote up in his thesis. Guix ships with a <code class="language-plaintext highlighter-rouge">--check</code> mode that rebuilds a package and compares the output to the previous build, and the whole project treats a mismatch as something to fix. Guix passes the Tuesday test about as well as anything on this list does.</p>

<hr />

<p>The common thread in the failing cases is that building a package and installing a package are the same step. A gemspec is Ruby because gems get built on the installer’s machine from it. System package managers are the opposite shape of the same problem: installing a package means dropping files into a live filesystem and reconciling them with whatever was already there.</p>

<p>Happy Taco Tuesday to Deno, Go, Bazel, Nix and Guix. 🌮</p>]]></content><author><name>Andrew Nesbitt</name><email>andrew@ecosyste.ms</email></author><category term="package-managers" /><summary type="html"><![CDATA[Like the Turing test but with more tacos.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nesbitt.io/images/boxes.png" /><media:content medium="image" url="https://nesbitt.io/images/boxes.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Standing on the shoulders of Homebrew</title><link href="https://nesbitt.io/2026/04/14/standing-on-the-shoulders-of-homebrew.html" rel="alternate" type="text/html" title="Standing on the shoulders of Homebrew" /><published>2026-04-14T10:00:00+00:00</published><updated>2026-04-14T10:00:00+00:00</updated><id>https://nesbitt.io/2026/04/14/standing-on-the-shoulders-of-homebrew</id><content type="html" xml:base="https://nesbitt.io/2026/04/14/standing-on-the-shoulders-of-homebrew.html"><![CDATA[<p><a href="https://github.com/lucasgelfond/zerobrew">zerobrew</a> and <a href="https://github.com/justrach/nanobrew">nanobrew</a> have been doing the rounds as fast alternatives to Homebrew, one written in Rust with the tagline “uv-style architecture for Homebrew packages” and the other in Zig with a 1.2 MB static binary and a benchmark table comparing itself favourably against the first. Both are upfront, once you scroll past the speedup numbers, that they resolve dependencies against homebrew-core, download the bottles that Homebrew’s CI built and Homebrew’s bandwidth bill serves, and parse the cask definitions that Homebrew contributors maintain.</p>

<p>They’re alternative clients for someone else’s registry, which is a perfectly reasonable thing to build, but the framing as a replacement glosses over what running a system package manager actually involves.</p>

<p>nanobrew’s README has a “what doesn’t work yet” section listing Ruby <code class="language-plaintext highlighter-rouge">post_install</code> hooks, build-from-source with custom options, conditional blocks in Brewfiles, and any complex Ruby DSL, while zerobrew handles source builds by falling back to “Homebrew’s Ruby DSL”, which I read as shelling out to the thing it’s meant to be replacing.</p>

<p>The parts of Homebrew they skip are the parts that are slow for a reason: evaluating arbitrary Ruby to discover what a package needs, running post-install hooks that touch the filesystem in package-specific ways, and handling the long tail of formulae that don’t reduce to “download this tarball and symlink it into a prefix”. Implementing only the bottle path and declaring the rest out of scope covers the easy 80% of packages and most of the benchmark wins.</p>

<p>zerobrew’s table reports a 4.4x speedup installing ffmpeg from a warm cache, nanobrew gets the same operation down to 287 milliseconds, and I keep trying to picture the developer who installs ffmpeg, uninstalls it, and installs it again on the same machine often enough for warm-cache reinstall time to be the number they care about.</p>

<p>A warm install is measuring how quickly you can clonefile a directory out of a content-addressable store, which is a fine thing to optimise but says almost nothing about the experience of setting up a new laptop or adding a tool you didn’t have yesterday. The cold-cache numbers are much closer together, occasionally slower than Homebrew when the bottle is large, because at that point everyone is waiting on the same CDN and there’s no clever data structure that makes bytes arrive faster.</p>

<p>I wrote about <a href="/2025/12/26/how-uv-got-so-fast.html">why uv is fast</a> a few months ago. The language rewrite was the least interesting part of that story. uv is fast because PEP 658 finally let Python resolvers fetch package metadata without executing <code class="language-plaintext highlighter-rouge">setup.py</code>, and because uv dropped eggs and <code class="language-plaintext highlighter-rouge">pip.conf</code> and a dozen other legacy paths that pip still carries. Homebrew already shipped its equivalent of PEP 658 in the <code class="language-plaintext highlighter-rouge">formula.json</code> API, and that’s the thing that made zerobrew and nanobrew possible in the first place, neither of them is solving the metadata-without-Ruby-evaluation problem because Homebrew already solved it for them.</p>

<p>zerobrew’s content-addressable store and APFS clonefile tricks would work equally well from Ruby, and nanobrew’s parallel downloads have been on by default in Homebrew since <a href="https://github.com/Homebrew/brew/pull/20975">4.7.0 last November</a>. The architectural choices are real improvements but they aren’t “we rewrote it in Zig” improvements, and a zero-startup-time binary matters a lot less when the operation behind it is a 40 MB download either way.</p>

<p>Most of the work in a package manager is the long tail: formulae that want a specific libiconv on an old macOS release, casks with notarisation quirks, post-install scripts that edit config files in ways you can’t predict in advance. None of it benchmarks. Whether either project still has a maintainer paying attention a year from now, once those issues start piling up in the tracker, is an open question. Both also chose Apache-2.0 rather than inheriting Homebrew’s BSD-2-Clause, which is legally fine and suggests the authors see themselves as building independent projects rather than contributing to the ecosystem they depend on.</p>

<p>The formula format is Turing-complete Ruby, which means the package definition and the client that interprets it are effectively the same artefact, and any move toward declarative package data has to either break the existing formulae or ship a Ruby evaluator as part of every client forever.</p>

<p>The <a href="https://formulae.brew.sh/api/formula.json">formula API</a> currently lists 8,308 formulae in homebrew-core and the <a href="https://formulae.brew.sh/api/cask.json">cask API</a> another 7,617 casks, plus <a href="https://github.com/search?q=homebrew-+in%3Aname+language%3Aruby&amp;type=repositories">roughly 34,000 <code class="language-plaintext highlighter-rouge">homebrew-*</code> Ruby repositories on GitHub</a> that look like third-party taps, all written against an internal DSL that was never meant to be a stable interchange format. The fast clients get to sidestep that problem by declaring it out of scope, which is a freedom the project they depend on doesn’t have.</p>

<p>The bottleneck isn’t Rust or Ruby, it’s the absence of a stable declarative package schema. Until that exists, every fast client is fast because Homebrew already did the slow work.</p>]]></content><author><name>Andrew Nesbitt</name><email>andrew@ecosyste.ms</email></author><category term="package-managers" /><category term="homebrew" /><summary type="html"><![CDATA[Rewriting the easy parts of Homebrew.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nesbitt.io/images/boxes.png" /><media:content medium="image" url="https://nesbitt.io/images/boxes.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Common Package Specification</title><link href="https://nesbitt.io/2026/04/13/common-package-specification.html" rel="alternate" type="text/html" title="Common Package Specification" /><published>2026-04-13T10:00:00+00:00</published><updated>2026-04-13T10:00:00+00:00</updated><id>https://nesbitt.io/2026/04/13/common-package-specification</id><content type="html" xml:base="https://nesbitt.io/2026/04/13/common-package-specification.html"><![CDATA[<p>The <a href="https://cps-org.github.io/cps/">Common Package Specification</a> went stable in CMake 4.3 last year and the name caught my attention because it sounds like it might be addressing the cross-ecosystem dependency problem I’ve <a href="/2026/01/27/the-c-shaped-hole-in-package-management.html">written about before</a>. Reading the spec, the “common” turns out to mean common across build systems rather than common across language ecosystems: it’s a JSON format that CMake and Meson and autotools can all read to find out where an installed library lives and how to link against it, replacing the mix of <code class="language-plaintext highlighter-rouge">.pc</code> files and <code class="language-plaintext highlighter-rouge">*Config.cmake</code> scripts that currently fill that role.</p>

<p>The schema is full of include paths, preprocessor defines, link flags, component types like <code class="language-plaintext highlighter-rouge">dylib</code> and <code class="language-plaintext highlighter-rouge">archive</code> and <code class="language-plaintext highlighter-rouge">interface</code> for header-only libraries, and feature strings like <code class="language-plaintext highlighter-rouge">c++11</code> and <code class="language-plaintext highlighter-rouge">gnu</code>, which makes sense given it came out of Kitware and the C++ tooling study group and is being driven by people building large C++ applications who are tired of every build system having its own incompatible way of describing the same installed library.</p>

<p>Conan can already <a href="https://github.com/conan-io/conan/blob/develop/conan/cps/cps.py">generate CPS files</a> for everything in ConanCenter, and CMake’s <code class="language-plaintext highlighter-rouge">find_package()</code> reads them with fallback to the older formats, so libraries built through that toolchain will start leaving <code class="language-plaintext highlighter-rouge">.cps</code> files in install prefixes whether anyone outside the C++ world notices or not. Each one is a small structured record of an installed binary: its location on disk, its version, what other components it requires, what platform it was built for.</p>

<p>For something like the <a href="https://github.com/ecosyste-ms/packages/issues/1261">binary dependency tracing</a> Vlad and I have been looking at, that’s a useful data source sitting alongside the symbol tables we’d be extracting anyway, particularly for the version field, which is the thing you can’t reliably recover from <code class="language-plaintext highlighter-rouge">nm</code> output and currently have to guess from filenames or distro package databases.</p>

<p>The closer fit is native extension builds in language package managers. Ruby’s mkmf has <a href="https://github.com/ruby/ruby/blob/master/lib/mkmf.rb"><code class="language-plaintext highlighter-rouge">pkg_config()</code> baked into it</a> and the <a href="https://github.com/ruby-gnome/pkg-config">pkg-config gem</a> reimplementing the format in pure Ruby has tens of millions of downloads, while node-gyp users shell out to <code class="language-plaintext highlighter-rouge">pkg-config</code> from <code class="language-plaintext highlighter-rouge">binding.gyp</code> action blocks to find headers and libraries at install time. These are doing exactly what CPS is designed to replace, and a CPS reader for mkmf would be a small piece of code, but the libraries that gems actually build against (libxml2, libpq, libsqlite3, openssl) ship <code class="language-plaintext highlighter-rouge">.pc</code> files because pkg-config has been around since 2000 and don’t yet ship <code class="language-plaintext highlighter-rouge">.cps</code> files because almost nothing outside CMake produces them.</p>

<p>There’s an <a href="https://github.com/cps-org/cps/issues/97">open proposal</a> to add a <code class="language-plaintext highlighter-rouge">package_url</code> field using purl identifiers so a CPS file could record which conan or vcpkg or distro package it came from, which would close a loop between the build-system world’s description format and the identifier scheme everything else has converged on.</p>

<p>Python has been moving on the adjacent problems independently, with <a href="https://peps.python.org/pep-0770/">PEP 770</a> reserving <code class="language-plaintext highlighter-rouge">.dist-info/sboms/</code> inside wheels for CycloneDX or SPDX documents describing bundled libraries, and auditwheel <a href="https://github.com/pypa/auditwheel/blob/main/src/auditwheel/sboms.py">already implementing it</a> by querying <code class="language-plaintext highlighter-rouge">dpkg</code> or <code class="language-plaintext highlighter-rouge">rpm</code> or <code class="language-plaintext highlighter-rouge">apk</code> at repair time to find which system package each grafted <code class="language-plaintext highlighter-rouge">.so</code> came from before writing the result as purls. CPS wouldn’t help here. Wheel consumers never compile anything, so what they need is provenance for what got bundled, and Python correctly reached for SBOM formats. The numpy 2.2.6 wheel I pulled to check still doesn’t have an SBOM in it despite the spec being accepted a year ago, which mostly tells you how long the tail is on rebuilding the world, and is part of why reconstructing this data from binaries after the fact stays useful even as the metadata standards land.</p>

<p><a href="https://peps.python.org/pep-0725/">PEP 725</a> declares <code class="language-plaintext highlighter-rouge">dep:generic/openssl</code> style requirements in <code class="language-plaintext highlighter-rouge">pyproject.toml</code> so build tools know what needs to be present before they start, using a purl-derived scheme that again has no relationship to CPS despite covering ground that pkg-config users would recognise.</p>

<p>None of these efforts reference each other much, which is roughly what you’d expect when the C dependency problem gets solved piecewise by whichever community hits it hardest, but the pieces are at least using compatible identifiers now, and a CPS file with a purl in it is something you could trace through to a PEP 770 SBOM entry without anyone having planned for that to work.</p>]]></content><author><name>Andrew Nesbitt</name><email>andrew@ecosyste.ms</email></author><category term="package-managers" /><summary type="html"><![CDATA[Not the cross-ecosystem format the name suggests.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nesbitt.io/images/boxes.png" /><media:content medium="image" url="https://nesbitt.io/images/boxes.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Package Registries and Pagination</title><link href="https://nesbitt.io/2026/04/10/package-registries-and-pagination.html" rel="alternate" type="text/html" title="Package Registries and Pagination" /><published>2026-04-10T10:00:00+00:00</published><updated>2026-04-10T10:00:00+00:00</updated><id>https://nesbitt.io/2026/04/10/package-registries-and-pagination</id><content type="html" xml:base="https://nesbitt.io/2026/04/10/package-registries-and-pagination.html"><![CDATA[<p>Package registries return every version a package has ever published in a single response, with no way to ask for less. The API formats were designed ten to twenty years ago when packages had tens of versions, not thousands, and they haven’t changed even as the ecosystems grew by orders of magnitude around them.</p>

<p>npm’s registry API dates to 2010 when there were a few hundred packages on the registry. <code class="language-plaintext highlighter-rouge">registry.npmjs.org/vite</code> now returns 37MB of JSON for 725 versions (gzip brings that to 4.4MB over the wire, but it’s still 37MB to parse) because each version entry includes the full README (up to 64KB), every dependency, every maintainer, the full <code class="language-plaintext highlighter-rouge">package.json</code> as published, and CouchDB revision metadata. <code class="language-plaintext highlighter-rouge">typescript</code> is 15MB for 3,758 versions, and even <code class="language-plaintext highlighter-rouge">express</code> is 800KB. None of these responses carry pagination headers of any kind, no <code class="language-plaintext highlighter-rouge">Link</code>, no <code class="language-plaintext highlighter-rouge">X-Total-Count</code>, no <code class="language-plaintext highlighter-rouge">X-Per-Page</code>, just <code class="language-plaintext highlighter-rouge">Content-Type: application/json</code> and standard cache controls.</p>

<p>npm offers an abbreviated metadata format through an <code class="language-plaintext highlighter-rouge">Accept: application/vnd.npm.install-v1+json</code> header that strips READMEs and most metadata, shrinking vite from 37MB to about 2MB, but it’s still unpaginated and the slimmed-down response drops fields like publication timestamps that tools need for <a href="/2026/03/04/package-managers-need-to-cool-down">dependency cooldown periods</a>, forcing anything that implements cooldown back onto the full 37MB document.</p>

<p>The <a href="https://github.com/renovatebot/renovate/discussions/38341">Renovate project</a> found the hard ceiling when, at 10,451 versions, their package metadata exceeded 100MB and <code class="language-plaintext highlighter-rouge">npm publish</code> started returning <code class="language-plaintext highlighter-rouge">E406 Not Acceptable: Your package metadata is too large (100.01 MB &gt; 100 MB)</code>. The only fix was unpublishing old versions, which also broke their Docker image builds since those depended on the npm package being publishable.</p>

<p>PyPI’s Simple API has roots going back to 2003 with setuptools, and PEP 503 formalized it in 2015 when there were about 70,000 packages. <code class="language-plaintext highlighter-rouge">pypi.org/pypi/boto3/json</code> returns all 2,011 releases in a single 2.8MB JSON response, and the Simple API that pip actually uses for resolution (<code class="language-plaintext highlighter-rouge">/simple/boto3/</code>) lists every file for every version as HTML anchor elements on one page. PEP 691 modernized the format to JSON in 2022 but didn’t add pagination, and the discussion thread shows nobody even raised it as a possibility. The PEP explicitly constrains against increasing the number of HTTP requests an installer has to make.</p>

<p>Packagist returns all 1,261 versions of <code class="language-plaintext highlighter-rouge">laravel/framework</code> inline and has since 2012. RubyGems’ JSON API sends all 516 versions of <code class="language-plaintext highlighter-rouge">rails</code> in 465KB, a format largely unchanged since 2009. Hex, pub.dev, Maven Central’s <code class="language-plaintext highlighter-rouge">maven-metadata.xml</code>, and Hackage all work the same way, each dating to between 2005 and 2014.</p>

<p>Go’s module proxy, designed in 2019 with the benefit of hindsight, keeps its <code class="language-plaintext highlighter-rouge">/@v/list</code> endpoint as plain text with one version string per line, so 1,865 versions of <code class="language-plaintext highlighter-rouge">aws-sdk-go</code> is 16KB. Maven’s metadata XML is similarly minimal at 12KB for spring-core. When the format only stores version strings the responses stay small regardless of how many versions accumulate.</p>

<p>NuGet’s V3 API, redesigned in 2015, is the only major registry that paginates version metadata on the server side, splitting versions into pages of 64 in its registration endpoint. Small packages get versions inlined in the index response while larger packages like <code class="language-plaintext highlighter-rouge">Microsoft.Extensions.DependencyInjection</code> (159 versions across 3 pages) return page pointers the client fetches separately. <a href="/2026/02/18/what-package-registries-could-borrow-from-oci">Docker Hub</a> also paginates tags at 100 per page with <code class="language-plaintext highlighter-rouge">next</code>/<code class="language-plaintext highlighter-rouge">previous</code> URLs in the response body. crates.io is halfway there: its versions API has a <code class="language-plaintext highlighter-rouge">meta</code> field with <code class="language-plaintext highlighter-rouge">total</code> and <code class="language-plaintext highlighter-rouge">next_page</code>, but for serde’s 315 versions it returns everything at once with <code class="language-plaintext highlighter-rouge">next_page: null</code>, and I haven’t found a crate large enough to trigger the second page.</p>

<p>The reason none of these registries paginate is that package managers need all versions visible at once to resolve dependency constraints. If <code class="language-plaintext highlighter-rouge">npm install</code> had to make ten round trips for every transitive dependency, installs would be painfully slow, so registries optimized for CDN cacheability instead: one canonical URL per package, one response, cache it at the edge. That trade-off made sense when the largest packages had a few dozen versions.</p>

<p>RubyGems’ Compact Index, Cargo’s sparse index, and Go’s <code class="language-plaintext highlighter-rouge">/@v/list</code> found a better path by stripping the response down to just what a resolver needs, serving it as a static file, and letting CDNs and HTTP range requests handle the rest. RubyGems’ compact index reduced dependency data from 202MB to 2.7MB compressed, and the responses stay small because they contain dependency metadata rather than everything a human might want to browse. npm and PyPI never made that split. When <code class="language-plaintext highlighter-rouge">npm install</code> fetches vite, it parses 37MB of READMEs, maintainer lists, and CouchDB revision history just to find out which version satisfies <code class="language-plaintext highlighter-rouge">^6.0.0</code>. Even gzipped, that metadata is eight times the size of the 522KB tarball it points to.</p>]]></content><author><name>Andrew Nesbitt</name><email>andrew@ecosyste.ms</email></author><category term="package-managers" /><category term="registries" /><summary type="html"><![CDATA[100MB of metadata for 10,451 versions.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nesbitt.io/images/boxes.png" /><media:content medium="image" url="https://nesbitt.io/images/boxes.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Package Security Defenses for AI Agents</title><link href="https://nesbitt.io/2026/04/09/package-security-defenses-for-ai-agents.html" rel="alternate" type="text/html" title="Package Security Defenses for AI Agents" /><published>2026-04-09T10:00:00+00:00</published><updated>2026-04-09T10:00:00+00:00</updated><id>https://nesbitt.io/2026/04/09/package-security-defenses-for-ai-agents</id><content type="html" xml:base="https://nesbitt.io/2026/04/09/package-security-defenses-for-ai-agents.html"><![CDATA[<p>Yesterday I wrote about <a href="/2026/04/08/package-security-problems-for-ai-agents">the package security problems AI agents face</a>: typosquatting, registry poisoning, lockfile manipulation, install-time code execution, credential theft, and cascading failures through the dependency graph. Agents inherit all the old package security problems but resolve, install, and propagate faster than any human can review.</p>

<p>There’s no silver bullet for securing agent coding workflows because LLMs can’t reliably distinguish safe packages and metadata from malicious ones, but these defenses can reduce the blast radius when something gets through. Some of them introduce friction, but agents can absorb that friction better than humans.</p>

<h2 id="for-people-using-ai-coding-platforms">For people using AI coding platforms</h2>

<h3 id="disable-install-scripts-by-default">Disable install scripts by default</h3>

<p>npm has <code class="language-plaintext highlighter-rouge">--ignore-scripts</code>, pip has <code class="language-plaintext highlighter-rouge">--only-binary :all:</code> to refuse sdists and force wheels, but neither defaults to off. Agent platforms should ship with install scripts disabled and require explicit opt-in per package. The <code class="language-plaintext highlighter-rouge">postinstall</code> script is the single most common vector for malicious packages, and agents have no way to evaluate whether a script is legitimate. Bun already defaults to not running lifecycle scripts for installed dependencies.</p>

<h3 id="dependency-cooldown-periods">Dependency cooldown periods</h3>

<p>New package versions shouldn’t be installable by agents for some window after publication, maybe 24-72 hours. I wrote about <a href="/2026/03/04/package-managers-need-to-cool-down">cooldown support across package managers</a> in more detail last month. Most malicious packages are detected and removed within days of upload. A cooldown means agents only resolve versions that have survived initial community and automated review. npm’s provenance attestations help here but aren’t sufficient alone. This could be enforced at the registry level, the resolver level, or the AI coding platform level.</p>

<h3 id="sandbox-package-installation">Sandbox package installation</h3>

<p>Agents should install packages in isolated environments with no network access after the download phase and no access to credentials, SSH keys, or environment variables. Container-based sandboxes or something like Landlock on Linux would work here, where the install step gets network access to fetch packages but everything after that runs without it. Even if a malicious install script executes, it can’t reach anything worth stealing.</p>

<h3 id="limit-which-registries-agents-can-resolve-from">Limit which registries agents can resolve from</h3>

<p>Agent configurations should support an allowlist of registries and scopes. An agent that only needs packages from your company’s private registry and a handful of vetted public packages shouldn’t be able to resolve arbitrary names from npm or PyPI. Companies already do this in their CI pipelines to prevent dependency confusion, and agents need the same treatment.</p>

<h3 id="pin-and-verify-lockfiles">Pin and verify lockfiles</h3>

<p>Agents should never regenerate a lockfile unless explicitly asked to. If a lockfile exists, the agent should install from it exactly. If the agent’s task requires adding a new dependency, it should produce the lockfile diff for review rather than installing and continuing. Lockfile-lint and similar tools should run as a gate before any agent-modified lockfile is accepted.</p>

<h3 id="require-package-provenance">Require package provenance</h3>

<p>Where registries support it (npm with sigstore, PyPI with Trusted Publishers), AI coding platforms should default to requiring provenance attestation. Packages without provenance get flagged or blocked. This doesn’t prevent all supply chain attacks but it makes account takeover and registry compromise harder.</p>

<h3 id="scope-agent-permissions-to-the-task">Scope agent permissions to the task</h3>

<p>An agent updating a README doesn’t need <code class="language-plaintext highlighter-rouge">npm install</code> permissions, and one running tests doesn’t need network access. Agent platforms should support task-scoped permission profiles rather than giving every agent the same broad access, covering both what packages an agent can install and what those packages can do once installed.</p>

<h3 id="treat-agent-tool-metadata-as-untrusted-input">Treat agent tool metadata as untrusted input</h3>

<p>MCP server descriptions, agent cards, skill descriptors, and plugin manifests should be treated as untrusted input, not as instructions. Agent platforms should parse metadata into structured fields and reject or sanitize freeform text before it reaches the LLM context.</p>

<h3 id="monitor-agent-dependency-behavior">Monitor agent dependency behavior</h3>

<p>Log every package install, version resolution, and registry query an agent makes, and diff these against expected behavior for the task. If an agent asked to fix a CSS bug runs <code class="language-plaintext highlighter-rouge">npm install crypto-utils</code>, that should page someone the same way an unexpected outbound network connection would in production. If an agent resolves a package version different from what’s in the lockfile, the task should halt and wait for human approval. Traditional package security tooling already surfaces these signals but most AI coding platforms don’t wire them into their agent workflows.</p>

<p>Failed installs matter too. When an agent tries to install a package that doesn’t exist, that’s likely a hallucinated name, and those names are <a href="/2025/12/10/slopsquatting-meets-dependency-confusion">slopsquatting</a> targets. Registries and AI coding platforms that log failed resolution attempts have an early warning system for which package names attackers should be racing to register.</p>

<h3 id="namespace-reservation-for-agent-ecosystems">Namespace reservation for agent ecosystems</h3>

<p>MCP server registries, A2A discovery services, and skill marketplaces should implement namespace reservation and verification, the way npm has org scopes and PyPI has verified publishers. Unverified packages in agent-specific namespaces should carry visible warnings, and agents should be configurable to reject unverified sources entirely.</p>

<h2 id="for-people-designing-ai-coding-platforms">For people designing AI coding platforms</h2>

<h3 id="your-agents-dependency-resolver-is-a-security-boundary">Your agent’s dependency resolver is a security boundary</h3>

<p>Every time your agent runs a package install, it’s making a trust decision. Treat the resolver the same way you’d treat an authentication system: define what it’s allowed to do, log what it actually does, and fail closed when something unexpected happens. If your agent can install arbitrary packages from public registries without approval, you’ve given the internet write access to your execution environment.</p>

<h3 id="separate-the-package-installation-phase-from-the-execution-phase">Separate the package installation phase from the execution phase</h3>

<p>Don’t let agents install and run in a single step. The install phase should fetch and verify packages against an allowlist or lockfile, and the execution phase should run in a sandboxed environment built from what was installed. You don’t <code class="language-plaintext highlighter-rouge">npm install</code> at runtime in production, and your agent shouldn’t either.</p>

<h3 id="design-for-the-agent-not-knowing-what-it-doesnt-know">Design for the agent not knowing what it doesn’t know</h3>

<p>A human developer might hesitate before installing a package they’ve never heard of, but an agent will install whatever it thinks solves the task. Require packages to come from a vetted list, flag new dependencies for human review, and reject packages below a popularity or age threshold.</p>

<h3 id="treat-every-mcp-server-and-plugin-as-a-dependency">Treat every MCP server and plugin as a dependency</h3>

<p>If your system connects to MCP servers, installs skills, or loads plugins, those are dependencies with the same risk profile as npm packages. Pin versions, verify provenance where possible, and audit what they do at install and runtime. Calling them “tools” or “skills” instead of “packages” doesn’t change the threat model.</p>

<h3 id="dont-give-agents-ambient-credentials">Don’t give agents ambient credentials</h3>

<p>Agents that inherit the developer’s shell environment get their SSH keys, API tokens, cloud credentials, and registry auth tokens, and a malicious package installed by the agent can read all of it. Provision agents with scoped, short-lived credentials that only cover what the current task requires. If your agent doesn’t need to push to a registry, it shouldn’t have a registry auth token in its environment.</p>

<h3 id="assume-your-agent-will-be-prompted-to-install-something-malicious">Assume your agent will be prompted to install something malicious</h3>

<p>Attackers will try to get your agent to install a bad package, and sometimes they’ll succeed. Design your system so that a single malicious install can’t exfiltrate credentials, can’t persist across tasks, can’t modify other agents’ environments, and can’t propagate to downstream systems. The blast radius of a compromised dependency should be one sandboxed task.</p>

<h3 id="build-a-dependency-audit-trail">Build a dependency audit trail</h3>

<p>Every package your agent installs, every version it resolves, every registry it queries should be logged and attributable to a specific task. When something goes wrong, you need to answer: which agent installed this, when, why, and what else did it touch? Traditional SCA tools can scan the result, but you also need the provenance of how that result was assembled, the same way you’d want reproducible builds.</p>

<h3 id="dont-forget-about-dependencies-after-installation">Don’t forget about dependencies after installation</h3>

<p>Agents are good at installing packages and bad at revisiting them. A dependency an agent pulled in six months ago to fix a one-off task is still in the tree, still getting loaded, and nobody has checked whether it’s been flagged since. Human developers at least occasionally see Dependabot PRs or hear about compromised packages through the grapevine. Agents don’t have a grapevine. If your platform lets agents add dependencies, it also needs a mechanism for surfacing when those dependencies go stale, get deprecated, or turn up in vulnerability databases.</p>]]></content><author><name>Andrew Nesbitt</name><email>andrew@ecosyste.ms</email></author><category term="security" /><category term="package-managers" /><category term="ai" /><summary type="html"><![CDATA[Lockfiles, sandboxes, and cooldown timers.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nesbitt.io/images/boxes.png" /><media:content medium="image" url="https://nesbitt.io/images/boxes.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Package Security Problems for AI Agents</title><link href="https://nesbitt.io/2026/04/08/package-security-problems-for-ai-agents.html" rel="alternate" type="text/html" title="Package Security Problems for AI Agents" /><published>2026-04-08T10:00:00+00:00</published><updated>2026-04-08T10:00:00+00:00</updated><id>https://nesbitt.io/2026/04/08/package-security-problems-for-ai-agents</id><content type="html" xml:base="https://nesbitt.io/2026/04/08/package-security-problems-for-ai-agents.html"><![CDATA[<p>I went through the recent <a href="https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026/">OWASP Top 10 for Agentic Applications</a> and pulled out the scenarios related to package management, which turn up in all ten categories and don’t sort neatly into any one of them, since a typosquatted MCP server is simultaneously a name attack, a registry attack, and a metadata poisoning vector.</p>

<h3 id="package-name-attacks">Package name attacks</h3>

<p>Typosquatting and namespace confusion are some of the oldest problems in package security. Agents make them worse because they resolve packages programmatically, without a human glancing at the name and noticing something is off.</p>

<ul>
  <li>An attacker registers an MCP server package on npm or PyPI with a name one character off from a popular one, and when an agent dynamically discovers and installs tools, it resolves the typosquatted package instead, treating it as legitimate.</li>
  <li>A malicious tool package named <code class="language-plaintext highlighter-rouge">report</code> gets resolved before the legitimate <code class="language-plaintext highlighter-rouge">report_finance</code> because of how the agent’s tool registry handles namespace collisions, causing misrouted queries and unintended data disclosure.</li>
  <li>LLMs hallucinating package names during code generation create install targets that don’t exist yet, and attackers can register those names on PyPI or npm with malicious payloads. I wrote about <a href="/2025/12/10/slopsquatting-meets-dependency-confusion">slopsquatting</a> in more detail last year.</li>
</ul>

<h3 id="registry-and-repository-attacks">Registry and repository attacks</h3>

<p>MCP servers, agent skills, and plugins are distributed through the same registries as traditional packages: npm, PyPI, crates.io, and platform-specific marketplaces. The registry trust problems that package managers have dealt with for years (compromised maintainer accounts, malicious uploads, manifest confusion) apply directly.</p>

<ul>
  <li>A compromised package registry serves signed-looking manifests, plugins, or agent descriptors containing tampered components, and because orchestration systems trust the registry, the poisoned artifacts distribute widely before anyone notices.</li>
  <li>The first <a href="https://snyk.io/blog/malicious-mcp-server-on-npm-postmark-mcp-harvests-emails/">in-the-wild malicious MCP server</a> was published as an npm package impersonating Postmark’s email service, secretly BCC’ing all emails to the attacker while agents that installed it had no indication anything was wrong.</li>
  <li>Agent discovery services like A2A function as new package registries, and they inherit the same problems: an attacker can register a fake peer using a cloned schema to intercept coordination traffic between legitimate agents, the same way you’d squat a package name on a public registry.</li>
  <li>Agent cards (the <code class="language-plaintext highlighter-rouge">/.well-known/agent.json</code> file) are package metadata by another name. A rogue peer can advertise exaggerated capabilities in its card, causing host agents to route sensitive requests through an attacker-controlled endpoint, analogous to a package claiming false capabilities in its manifest.</li>
</ul>

<h3 id="metadata-and-descriptor-poisoning">Metadata and descriptor poisoning</h3>

<p>Package metadata has always been a trust boundary: manifest confusion (where published metadata doesn’t match actual package contents) and starjacking (where a package claims association with a popular repo through its metadata) are established attacks. Agent tooling adds a new dimension because agents interpret metadata as instructions, not just data.</p>

<ul>
  <li>Hidden instructions embedded in an MCP server’s published package metadata get interpreted by the host agent as trusted guidance. In one <a href="https://invariantlabs.ai/blog/mcp-security-notification-tool-poisoning-attacks">demonstrated case</a>, a malicious MCP tool package hid commands in its descriptor that caused the assistant to exfiltrate private repo data when invoked.</li>
  <li>Package READMEs processed through RAG can contain hidden instruction payloads that silently redirect an agent to misuse connected tools or send data to external endpoints. The README is package metadata that traditional security tooling rarely inspects for malicious content.</li>
  <li>A popular RAG plugin distributed as a package and fetching context from a third-party indexer can be gradually poisoned by seeding the indexer with crafted entries, biasing the agent over time until it starts exfiltrating sensitive information during normal use.</li>
</ul>

<h3 id="dependency-resolution-and-lockfile-attacks">Dependency resolution and lockfile attacks</h3>

<p>Lockfile manipulation and pinning evasion are well-understood supply chain attacks. Agents amplify them because they routinely regenerate lockfiles, install fresh dependencies, and resolve versions without comparing against a known-good baseline.</p>

<ul>
  <li>An agent regenerating a lockfile from unpinned dependency specs during a “fix build” task in an ephemeral sandbox will resolve fresh versions, potentially pulling in a backdoored minor release that wasn’t in the original lockfile.</li>
  <li>Agents running automated dependency updates or vibe-coding sessions install packages without verifying them against a known-good lockfile. A coding agent with auto-approved tools that runs <code class="language-plaintext highlighter-rouge">npm install</code> or <code class="language-plaintext highlighter-rouge">pip install</code> can be manipulated into resolving a different version than a human developer would have chosen, or into installing an entirely new dependency that runs hostile code at install time.</li>
</ul>

<h3 id="install-time-and-import-time-code-execution">Install-time and import-time code execution</h3>

<p>Install scripts (<code class="language-plaintext highlighter-rouge">postinstall</code> in npm, <code class="language-plaintext highlighter-rouge">setup.py</code> in pip) have been the primary vector for malicious packages for years. The OpenSSF Package Analysis project exists largely to detect this pattern. Agents make it worse because they run installs with broader permissions and less scrutiny than a developer at a terminal.</p>

<ul>
  <li>Malicious package installs escalate beyond a supply-chain compromise when hostile code executes during installation or import with whatever permissions the agent has, which are often broad because the agent needs filesystem and network access to do its job. A developer running <code class="language-plaintext highlighter-rouge">npm install</code> might notice a suspicious <code class="language-plaintext highlighter-rouge">postinstall</code> script in their terminal output. An agent running the same command as part of a “fix build” or “patch server” task won’t.</li>
  <li>During automated dependency updates or self-repair tasks, agents run unreviewed <code class="language-plaintext highlighter-rouge">npm install</code> or <code class="language-plaintext highlighter-rouge">pip install</code> commands, and any package with a malicious install script executes with the agent’s full permissions before any human sees what happened. The attack surface here is identical to traditional install-script malware, but the window between install and detection is wider because no one is watching.</li>
</ul>

<h3 id="credential-and-secret-leakage-through-packages">Credential and secret leakage through packages</h3>

<p>Malicious packages exfiltrating credentials at install time is a well-documented pattern across npm, PyPI, and RubyGems. Agents widen the blast radius because they often hold more credentials than a typical developer environment and install packages without human review.</p>

<ul>
  <li>The <a href="https://www.stepsecurity.io/blog/supply-chain-security-alert-popular-nx-build-system-package-compromised-with-data-stealing-malware">poisoned nx/debug release</a> on npm was automatically installed by coding agents, enabling a hidden backdoor that exfiltrated SSH keys and API tokens. The compromise propagated across agentic workflows because no human reviewed the install, turning a single malicious package release into a supply-chain breach that moved faster than traditional incident response could track.</li>
  <li>Agents that install MCP server packages or plugins grant those packages access to environment variables, API keys, and filesystem paths. A malicious package published under a plausible name can harvest credentials the same way traditional supply chain attacks do, but with access to whatever the agent is authorized to use.</li>
</ul>

<h3 id="cascading-failures-through-the-dependency-graph">Cascading failures through the dependency graph</h3>

<p>Cascading breakage from a single bad release is a familiar problem in package management. When left-pad was unpublished from npm in 2016, thousands of builds broke within hours. When colors.js shipped a sabotaged release in 2022, projects that pinned loosely picked it up automatically. In agent systems the dependency graph includes not just code packages but MCP servers, plugins, and peer agents, and the propagation is faster because agents resolve, install, and deploy without waiting for a human to notice something is wrong.</p>

<ul>
  <li>A poisoned or faulty package release pulled by an orchestrator agent propagates automatically to all connected agents, amplifying the breach beyond its origin. In traditional package management a developer might notice a broken build and pin a version. An agent with auto-approved installs just keeps going, and every downstream agent that depends on the orchestrator’s output inherits the compromised dependency.</li>
  <li>When two or more agents rely on each other’s outputs they create a feedback loop that magnifies initial errors. A bad dependency update in one agent’s package tree compounds through the loop: agent A installs a corrupted package, produces bad output, agent B consumes that output and makes decisions based on it, and the error amplifies with each cycle until the system is producing nonsense at scale.</li>
</ul>

<h3 id="skill-and-plugin-installation">Skill and plugin installation</h3>

<p>Agent coding platforms have their own packaging systems for skills, plugins, hooks, and extensions, and these turn out to have the same vulnerabilities that traditional package managers spent years learning about. OpenClaw, which has accumulated <a href="https://days-since-openclaw-cve.com/">238 CVEs since February 2026</a>, provides the perfect case study. Malicious skill archives can use path traversal sequences to write files outside the intended installation directory during <code class="language-plaintext highlighter-rouge">skills install</code> or <code class="language-plaintext highlighter-rouge">hooks install</code> (<a href="https://nvd.nist.gov/vuln/detail/CVE-2026-28486">CVE-2026-28486</a>, <a href="https://nvd.nist.gov/vuln/detail/CVE-2026-28453">CVE-2026-28453</a>), and the skill frontmatter <code class="language-plaintext highlighter-rouge">name</code> field gets interpolated into file paths unsanitized during sandbox mirroring (<a href="https://nvd.nist.gov/vuln/detail/CVE-2026-28457">CVE-2026-28457</a>). Scoped plugin package names containing <code class="language-plaintext highlighter-rouge">..</code> can escape the extensions directory entirely (<a href="https://nvd.nist.gov/vuln/detail/CVE-2026-28447">CVE-2026-28447</a>).</p>

<p>OpenClaw also auto-discovers and loads plugins from <code class="language-plaintext highlighter-rouge">.OpenClaw/extensions/</code> without verifying trust, so cloning a repository that includes a crafted workspace plugin runs arbitrary code the moment the agent starts (<a href="https://nvd.nist.gov/vuln/detail/CVE-2026-32920">CVE-2026-32920</a>). Hook module paths passed to dynamic <code class="language-plaintext highlighter-rouge">import()</code> aren’t constrained, giving anyone with config access a code execution primitive (<a href="https://nvd.nist.gov/vuln/detail/CVE-2026-28456">CVE-2026-28456</a>). The exec allowlist trusts writable package-manager directories like <code class="language-plaintext highlighter-rouge">/opt/homebrew/bin</code> and <code class="language-plaintext highlighter-rouge">/usr/local/bin</code> by default, so an attacker who can write to those paths (which is anyone who can run <code class="language-plaintext highlighter-rouge">brew install</code> or <code class="language-plaintext highlighter-rouge">pip install --user</code>) can plant a trojan binary that the allowlist treats as safe (<a href="https://nvd.nist.gov/vuln/detail/CVE-2026-32009">CVE-2026-32009</a>). Environment variables like <code class="language-plaintext highlighter-rouge">NODE_OPTIONS</code> or <code class="language-plaintext highlighter-rouge">LD_PRELOAD</code> injected through config execute arbitrary code at gateway startup (<a href="https://nvd.nist.gov/vuln/detail/CVE-2026-22177">CVE-2026-22177</a>).</p>

<p>These are familiar problems if you’ve worked on package manager security: path traversal in archives, untrusted input in file paths, auto-loading from working directories, trusting mutable filesystem locations. Agent coding platforms are rebuilding package management from scratch and rediscovering the same bugs. The difference is that the old bugs played out over hours or days, gated by humans reviewing installs, noticing broken builds, and pinning versions. Agents compress that timeline. They resolve, install, execute, and propagate before anyone is in the loop, with broader permissions than a developer typically has.</p>]]></content><author><name>Andrew Nesbitt</name><email>andrew@ecosyste.ms</email></author><category term="security" /><category term="package-managers" /><category term="ai" /><category term="reference" /><summary type="html"><![CDATA[Packages all the way down, agents all the way up.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nesbitt.io/images/boxes.png" /><media:content medium="image" url="https://nesbitt.io/images/boxes.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Who Built This?</title><link href="https://nesbitt.io/2026/04/07/who-built-this.html" rel="alternate" type="text/html" title="Who Built This?" /><published>2026-04-07T10:00:00+00:00</published><updated>2026-04-07T10:00:00+00:00</updated><id>https://nesbitt.io/2026/04/07/who-built-this</id><content type="html" xml:base="https://nesbitt.io/2026/04/07/who-built-this.html"><![CDATA[<p>Michael Stapelberg <a href="https://michael.stapelberg.ch/posts/2026-04-05-stamp-it-all-programs-must-report-their-version/">wrote last week</a> about Go’s automatic VCS stamping: since Go 1.18, every binary built from a git checkout embeds the commit hash, timestamp, and dirty flag, queryable with <code class="language-plaintext highlighter-rouge">go version -m</code> or <code class="language-plaintext highlighter-rouge">runtime/debug.ReadBuildInfo()</code> at runtime. His argument is that every program should do this, so you can always answer “what version is running in production?” without guessing. Go is unusual in doing this by default, and the rest of the <a href="/2026/01/03/the-package-management-landscape.html">package management landscape</a> varies wildly in how it handles this, if it handles it at all.</p>

<h2 id="compiled-languages">Compiled languages</h2>

<p>Rust’s Cargo has <a href="https://github.com/rust-lang/cargo/issues/5629">an open issue</a> proposing that <code class="language-plaintext highlighter-rouge">cargo package</code> record the git commit hash in published crates, but nothing has been accepted beyond a <code class="language-plaintext highlighter-rouge">.cargo_vcs_info.json</code> file in the packaged crate, so the conventional approach is a <code class="language-plaintext highlighter-rouge">build.rs</code> script using crates like <a href="https://github.com/rustyhorde/vergen">vergen</a> or <a href="https://github.com/baoyachi/shadow-rs">shadow-rs</a> to emit <code class="language-plaintext highlighter-rouge">cargo:rustc-env</code> directives that become compile-time environment variables readable with <code class="language-plaintext highlighter-rouge">env!()</code>. You get the SHA, branch, timestamp, and dirty flag, but you have to opt in, wire it up, and expose it through a <code class="language-plaintext highlighter-rouge">--version</code> flag or similar, and there’s no way to inspect an arbitrary Rust binary externally.</p>

<p><a href="https://github.com/dotnet/sourcelink">SourceLink</a>, now built into the .NET SDK, makes .NET the closest to Go’s approach. It sets <code class="language-plaintext highlighter-rouge">AssemblyInformationalVersion</code> to something like <code class="language-plaintext highlighter-rouge">1.0.0+60002d50a...</code>, embedding the full commit SHA alongside the repository URL for debugger source fetching. <a href="https://github.com/adamralph/minver">MinVer</a> derives the version entirely from git tags with no configuration file, and <a href="https://github.com/GitTools/GitVersion">GitVersion</a> computes semver from branch topology. It’s opt-in, but the tooling is mature enough that a .NET developer who wants stamping can get it with a single package reference and no build script.</p>

<p>Java’s ecosystem relies on <a href="https://github.com/git-commit-id/git-commit-id-maven-plugin">git-commit-id-maven-plugin</a>, which generates a <code class="language-plaintext highlighter-rouge">git.properties</code> file and can inject metadata into <code class="language-plaintext highlighter-rouge">META-INF/MANIFEST.MF</code>. Spring Boot’s actuator <code class="language-plaintext highlighter-rouge">/info</code> endpoint reads <code class="language-plaintext highlighter-rouge">git.properties</code> automatically, which means a lot of Spring Boot applications in production actually do have VCS info available, even if the developers who configured it don’t think of it as “stamping.” You can inspect a JAR’s manifest with <code class="language-plaintext highlighter-rouge">unzip -p foo.jar META-INF/MANIFEST.MF</code>, and <code class="language-plaintext highlighter-rouge">Package.getImplementationVersion()</code> reads it at runtime, though without the plugin you get whatever the maintainer put in the POM version field and nothing else. Gradle has equivalents, and sbt needs two plugins (<a href="https://github.com/sbt/sbt-buildinfo">sbt-buildinfo</a> plus <a href="https://github.com/sbt/sbt-git">sbt-git</a>) to get the same result.</p>

<p>Swift Package Manager has no stamping mechanism at all, and a third-party <a href="https://github.com/DimaRU/PackageBuildInfo">PackageBuildInfo</a> plugin that shells out to git during the build is about all that exists. SwiftPM has a registry protocol (SE-0292, SE-0391) and private registries exist, but there’s no public centralized registry and most packages still resolve directly from git repositories, so the VCS metadata is right there at build time. It clones the repo, checks out the tagged commit, and then throws away everything except the source files. Of all the compiled language toolchains, SwiftPM would have the easiest time stamping and yet doesn’t.</p>

<p>Bazel’s <code class="language-plaintext highlighter-rouge">--workspace_status_command</code> flag runs a user-provided script that prints key-value pairs. Keys prefixed <code class="language-plaintext highlighter-rouge">STABLE_</code> invalidate the build cache when they change; others are “volatile” and stale values may be used without triggering a rebuild. The mechanism is powerful and built-in, but the documentation is notoriously confusing and the stable-vs-volatile distinction trips people up regularly.</p>

<h2 id="interpreted-languages">Interpreted languages</h2>

<p>For interpreted languages, “stamping” means something slightly different, since there’s no compiled binary to embed data in: can you determine what version or commit an installed package came from at runtime?</p>

<p>Composer’s <code class="language-plaintext highlighter-rouge">InstalledVersions::getReference('vendor/pkg')</code>, available since version 2.0, returns the git commit SHA of every installed PHP package, backed by <code class="language-plaintext highlighter-rouge">vendor/composer/installed.json</code>. This works for both source and dist installs because Packagist records the commit SHA that each tag points to in its API metadata, and Composer preserves it through the lock file into runtime. No other interpreted language package manager preserves this much VCS metadata with this little configuration.</p>

<p>Python’s <code class="language-plaintext highlighter-rouge">importlib.metadata.version('pkg')</code> gives you the version string but no VCS revision unless you use <a href="https://github.com/pypa/setuptools-scm">setuptools-scm</a> or similar to bake the commit hash in at build time. PEP 610 specifies a <code class="language-plaintext highlighter-rouge">direct_url.json</code> for packages installed directly from VCS, which records the commit hash, but anything installed from PyPI lost its git SHA when the sdist or wheel was built. npm, pnpm, and Yarn make <code class="language-plaintext highlighter-rouge">package.json</code> version available at runtime but nothing more; npm provenance attestations link published packages to specific commits via Sigstore, though that’s registry metadata rather than something embedded in the package itself. RubyGems exposes version at runtime through <code class="language-plaintext highlighter-rouge">Gem::Specification</code> and the gemspec <code class="language-plaintext highlighter-rouge">metadata</code> hash allows arbitrary keys, but there’s no standard field for git SHA and no convention for using one.</p>

<h2 id="system-package-managers">System package managers</h2>

<p>dpkg stores package version (e.g. <code class="language-plaintext highlighter-rouge">1.2.3-1</code>) queryable with <code class="language-plaintext highlighter-rouge">dpkg -s</code>, and a <code class="language-plaintext highlighter-rouge">Vcs-Git</code> field exists in source package metadata, but that field never propagates to installed binary packages. RPM actually has a dedicated <code class="language-plaintext highlighter-rouge">VCS</code> tag (tag 5034) that can store the upstream repository URL and potentially a commit SHA, but most Fedora RPMs don’t bother setting it.</p>

<p>Arch’s pacman has a clever approach for VCS packages: packages suffixed <code class="language-plaintext highlighter-rouge">-git</code> run a <code class="language-plaintext highlighter-rouge">pkgver()</code> function in the PKGBUILD that encodes the commits-since-last-tag and short hash into the version string itself, like <code class="language-plaintext highlighter-rouge">1.0.3.r12.ga1b2c3d</code>, so the version you see in <code class="language-plaintext highlighter-rouge">pacman -Qi</code> actually contains the commit info. Regular packages built from release tarballs just carry the upstream version number, though.</p>

<p>Homebrew records the formula URL (typically a tarball) and its SHA256, plus a Homebrew-specific <code class="language-plaintext highlighter-rouge">revision</code> field for rebuilds, but no upstream git commit survives installation. Flatpak and Snap both have version metadata in their app manifests but no VCS revision field in either format.</p>

<p>Nix is where Stapelberg’s post originates, and it’s a good illustration of the problem: store paths encode a content hash, not a VCS revision, and fetchers like <code class="language-plaintext highlighter-rouge">fetchFromGitHub</code> download a tarball with no <code class="language-plaintext highlighter-rouge">.git</code> directory. Even <code class="language-plaintext highlighter-rouge">builtins.fetchGit</code> strips <code class="language-plaintext highlighter-rouge">.git</code> for reproducibility. The <code class="language-plaintext highlighter-rouge">.rev</code> attribute exists during Nix evaluation but isn’t written to the store, so Stapelberg’s <a href="https://github.com/stapelberg/nix">go-vcs-stamping.nix</a> overlay has to bridge that gap for Go specifically, and the underlying problem affects every language built through Nix.</p>

<h2 id="container-images">Container images</h2>

<p>OCI images have their own annotation spec for this: the <a href="https://github.com/opencontainers/image-spec/blob/main/annotations.md"><code class="language-plaintext highlighter-rouge">org.opencontainers.image.revision</code></a> label carries the VCS commit hash, and <code class="language-plaintext highlighter-rouge">org.opencontainers.image.source</code> points to the repository URL. <code class="language-plaintext highlighter-rouge">docker buildx</code> can set these automatically from git context, and GitHub Actions’ <code class="language-plaintext highlighter-rouge">docker/metadata-action</code> populates them from the workflow environment, so a CI-built image can carry its commit SHA and repo URL without any manual wiring.</p>

<p>Plenty of Dockerfiles don’t set these labels in practice, and even when they’re present they describe the image build, not necessarily the application inside it. An image built from a Go binary that was itself built without VCS stamping will have the commit that changed the Dockerfile, which may or may not be the commit that changed the application code, so image-level and application-level stamping end up being two separate problems.</p>

<h2 id="source-archives">Source archives</h2>

<p>Git’s own <a href="https://git-scm.com/docs/git-archive"><code class="language-plaintext highlighter-rouge">git archive</code></a> command supports <a href="https://git-scm.com/docs/gitattributes#_creating_an_archive"><code class="language-plaintext highlighter-rouge">export-subst</code></a> in <code class="language-plaintext highlighter-rouge">.gitattributes</code>, expanding placeholders like <code class="language-plaintext highlighter-rouge">$Format:%H$</code> into the full commit hash, which is the intended mechanism for embedding commit info in archives without <code class="language-plaintext highlighter-rouge">.git</code>. GitHub, GitLab, Gitea, and Forgejo all use <code class="language-plaintext highlighter-rouge">git archive</code> internally for their downloadable tarballs and zipballs, so <code class="language-plaintext highlighter-rouge">export-subst</code> works on all of them. If you add <code class="language-plaintext highlighter-rouge">version.txt export-subst</code> to your <code class="language-plaintext highlighter-rouge">.gitattributes</code> and put <code class="language-plaintext highlighter-rouge">$Format:%H$</code> in that file, the tarball will contain the full commit hash.</p>

<p>The catch is reproducibility. Abbreviated hash placeholders like <code class="language-plaintext highlighter-rouge">$Format:%h$</code> produce different-length output depending on the number of objects in the repository, and GitHub’s servers don’t always agree on object counts. The same tarball URL can return different contents at different times, which breaks checksum verification. <a href="https://github.com/NixOS/nixpkgs/issues/84312">NixOS/nixpkgs#84312</a> documents this problem in detail. Full hashes (<code class="language-plaintext highlighter-rouge">%H</code>) are stable, but ref-dependent placeholders like <code class="language-plaintext highlighter-rouge">%d</code> change as branches move. The mechanism works, but anyone who checksums tarballs, which is most package managers, has to treat <code class="language-plaintext highlighter-rouge">export-subst</code> repos as a source of non-determinism.</p>

<p>The same thing happens with package archives, where the version from the manifest file survives but the commit that produced it doesn’t unless the build backend explicitly stamped it in. (An <a href="https://github.com/npm/npm/issues/20213">npm bug in 6.9.1</a> once accidentally included <code class="language-plaintext highlighter-rouge">.git</code> directories in published tarballs, and it was treated as a serious defect.) A developer tags a commit, CI builds an artifact from that tag, the build process strips <code class="language-plaintext highlighter-rouge">.git</code>, and the resulting package carries only the version string.</p>

<h2 id="trusted-publishing-and-embedded-stamping">Trusted publishing and embedded stamping</h2>

<p>Trusted publishing through Sigstore addresses this from the registry side. When a package is published from CI with OIDC-based trusted publishing, the registry records which commit, repository, and workflow produced it, with a cryptographic signature in a transparency ledger. npm and PyPI both support this today. The provenance metadata lives at the registry rather than in the artifact, but you can look up an artifact’s attestation by its hash, so if you have the artifact you can trace it back to the commit that produced it without the artifact itself needing to carry that information.</p>

<p><a href="https://www.softwareheritage.org/">Software Heritage</a> could eventually enable something similar from the source side. They archive public source code repositories and assign intrinsic identifiers (<a href="https://docs.softwareheritage.org/devel/swh-model/persistent-identifiers.html">SWHIDs</a>) based on content hashes, so in principle you could go the other direction too: given a source tree or file, look up which commits and repositories it appeared in. That archive is already large and growing, though the tooling to make these lookups practical for everyday debugging isn’t there yet.</p>

<p>All this research got me thinking about how it could integrate with <a href="https://github.com/git-pkgs/git-pkgs">git-pkgs</a>, which already tracks the dependency side of this: who added a package, when it changed, what the version history looks like in your repo. Its <code class="language-plaintext highlighter-rouge">browse</code> command opens the installed source of a package in your editor, but that’s the installed files with no git history.</p>

<p>If packages reliably carried their source commit, there’s a more interesting version of that command: clone the upstream repository and check out the exact commit your installed version was built from. You’d get <code class="language-plaintext highlighter-rouge">git log</code>, <code class="language-plaintext highlighter-rouge">git blame</code>, the full context of what changed between the version you have and the version you’re upgrading to, all from your local terminal. The stamping metadata is the missing link between “I depend on this package at this version” and “here is the code that produced it, with its history.”</p>]]></content><author><name>Andrew Nesbitt</name><email>andrew@ecosyste.ms</email></author><category term="package-managers" /><category term="security" /><category term="supply-chain" /><summary type="html"><![CDATA[Tracing a dependency back to its source commit.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nesbitt.io/images/boxes.png" /><media:content medium="image" url="https://nesbitt.io/images/boxes.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">The Cathedral and the Catacombs</title><link href="https://nesbitt.io/2026/04/06/the-cathedral-and-the-catacombs.html" rel="alternate" type="text/html" title="The Cathedral and the Catacombs" /><published>2026-04-06T10:00:00+00:00</published><updated>2026-04-06T10:00:00+00:00</updated><id>https://nesbitt.io/2026/04/06/the-cathedral-and-the-catacombs</id><content type="html" xml:base="https://nesbitt.io/2026/04/06/the-cathedral-and-the-catacombs.html"><![CDATA[<p>Eric Raymond’s <a href="http://www.catb.org/~esr/writings/cathedral-bazaar/">The Cathedral and the Bazaar</a> is almost thirty years old and people are still finding new ways to extend the metaphor. Drew Breunig recently described a third mode, the <a href="https://www.dbreunig.com/2026/03/26/winchester-mystery-house.html">Winchester Mystery House</a>, for the sprawling codebases that agentic AI produces: rooms that lead nowhere, staircases into ceilings, a single builder with no plan. That piece got me thinking, though it shares a blind spot with every other response to Raymond I’ve read.</p>

<p>As the P2P Foundation <a href="https://blog.p2pfoundation.net/revisiting-the-cathedralbazaar-metaphor-why-both-eric-raymond-and-nicholas-carr-got-it-partly-wrong/2007/08/11">pointed out</a>, historical cathedrals were communal projects that mobilized entire communities through donations and voluntary labour, not top-down designs imposed by a single architect, and the bazaar isn’t really a market when nothing is priced and there are no merchants. But the responses all stay within the same frame: process, governance, and who builds.</p>

<p>I find it odd that in nearly three decades of cathedral-and-bazaar discourse, nobody has written about the catacombs: the dependency graph underneath every project, the deep network of transitive packages and shared libraries and unmaintained infrastructure that the visible building rests on, regardless of whether a cathedral architect or a bazaar crowd built it.</p>

<p>When Raymond wrote that “given enough eyeballs, all bugs are shallow”, he was talking about the thing you can see: the project, its source, its public development process. Linus’s law assumes people are looking. The dependency tree breaks that assumption.</p>

<p>A typical JavaScript project can pull in hundreds of transitive dependencies that nobody on the team has read, written by maintainers they’ve never heard of, last updated at various points over the past several years. The cathedral’s architects didn’t inspect the catacombs before building on top of them, and the bazaar’s crowd didn’t either, because in both cases the construction process is what gets all the attention while the foundations are treated as someone else’s concern.</p>

<p>Josh Bressers <a href="https://opensourcesecurity.io/2026/01-cathedral-megachurch-bazaar/">argued</a> that successful open source projects are really megachurches now, large structured organizations with budgets and governance, while the actual bazaar is the neglected hobbyist layer underneath. He comes closest to this when he identifies that neglected layer, and Nadia Eghbal’s <a href="https://www.fordfoundation.org/work/learning/research-reports/roads-and-bridges-the-unseen-labor-behind-our-digital-infrastructure/">Roads and Bridges</a> documented the same neglect as an infrastructure funding problem back in 2016. But both are talking about maintainers and their working conditions, which is still a question about people and process.</p>

<p>It’s not just that the maintainers of your transitive dependencies are overworked or under-resourced (though they are). It’s that the dependency graph itself is a load-bearing structure that nobody designed and nobody audits as a whole. There are partial efforts: lockfiles, SBOMs, dependency scanners, distro maintainers who vet packages one at a time. But none of them look at the graph as a connected system. It assembled itself through thousands of independent decisions by maintainers who each added whatever looked useful, and the result is an unmapped network of tunnels under the building that happens to hold the floor up.</p>

<p>Real catacombs are underground networks that were built for one purpose, repurposed for another, and eventually forgotten about until someone discovers they’ve been structurally compromised or that unauthorized people have been using them to get into buildings above. A package gets written to solve a small problem, other packages start depending on it, applications pull it in transitively, and eventually it’s load-bearing infrastructure maintained by someone who wrote it on a weekend years ago and barely remembers it exists.  Every package ecosystem has some version of this, though <a href="/2026/03/31/npms-defaults-are-bad.html">npm’s defaults</a> are especially good at making it worse.</p>

<p>And like real catacombs, they get used as ways in. The <a href="https://www.openwall.com/lists/oss-security/2024/03/29/4">xz backdoor</a> didn’t try to get through the front door of any distribution. A co-maintainer spent two years building trust in a compression library that sits deep in the dependency graph of almost every Linux system, then planted obfuscated code in the build system. The <a href="https://blog.npmjs.org/post/180565383195/details-about-the-event-stream-incident">event-stream attack</a> took over a single abandoned npm package and used it to target a completely different application downstream. Neither attack targeted the cathedral or the bazaar directly, they used the dependency graph as a tunnel network to reach targets that were well-defended at every visible entrance.</p>

<p>Whether your project is built cathedral-style with careful central control, or bazaar-style with open contribution, or Winchester Mystery House-style by an AI that doesn’t know what a staircase is for, makes very little difference to the structural risk underneath. A cathedral with meticulous code review and a strict merge process installs its dependencies from the same registries as the most chaotic bazaar project, inherits the same transitive chains, runs the same lifecycle scripts during build. The governance model describes how the floors are laid, but the dependency graph underneath comes from the same place.</p>

<p>Can you imagine what the basement of the Winchester Mystery House looks like? AI coding agents tend to pull in dependencies much more aggressively than most humans would, extending the graph in ways that are hard to review even in principle. And since early 2026, a growing number of people have been pointing AI at open source projects to find security vulnerabilities, sending automated explorers into the catacombs and filing reports faster than maintainers can triage them.</p>]]></content><author><name>Andrew Nesbitt</name><email>andrew@ecosyste.ms</email></author><category term="open-source" /><category term="dependencies" /><category term="security" /><summary type="html"><![CDATA[Stretching a metaphor deep into the floor.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nesbitt.io/images/boxes.png" /><media:content medium="image" url="https://nesbitt.io/images/boxes.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>