2026-07-13 The Intel Audit That Could Not See Shims

What I set out to do

Finish the “declare it where it’s used” sweep from 2026-07-13 Darwin Nixpkgs Regressions Blocking hm switch by closing the last undeclared-dependency surface: Claude’s LSP plugins. It turned into an archaeology dig that ended with ~6 GB of Intel binaries deleted and a much better answer to a question Adam has asked before: how did an x86_64 toolchain survive my migration audit?

What I actually did

Coupled the LSP plugins to their binaries. A Claude LSP plugin ships no server — it is a manifest whose command is a bare name resolved from PATH ("command": "pyright-langserver", 40K of README on disk). So the plugin flag in claude.nix and the package in packages.nix were two unrelated declarations that merely happened to agree. That is exactly how pyright ended up installed at 182 MB with its plugin disabled. Both halves now derive from one attrset, lib/lsp-servers.nix: packages.nix installs the values, claude.nix enables the keys. Verified by evaluating both states — flip devTools.languageServers off and all four packages and all four plugin flags vanish together (36ed591, after dropping pyright/pyrefly in d128db4).

It lives in lib/ rather than under coding-agents/ because the headless server profile can enable LSPs for editing over SSH without importing claude.nix at all.

Which surfaced a dead LSP. rust-analyzer-lsp was enabled against a binary that could not start: rust-analyzer on PATH was rustup’s shim, which proxies to a toolchain component that was not installed, so it re-exec’d itself, printed “unavailable for the active toolchain” and exited 0. Rust intelligence had been dead in both Claude and nvim — reported nowhere but the /plugin Errors tab, exactly as the docs promise.

And the shim’s excuse was the real find. The component was unavailable because the active rustup toolchain was stable-x86_64-apple-darwin — an Intel toolchain on an ARM Mac. cargo/rustc were 1.74.1, from October 2023, running under Rosetta. Replaced rustup with Nix-declared rustc/cargo/clippy/rustfmt (f315347): 1.96, aarch64-apple-darwin, verified by compiling and running a program and checking the output binary was arm64, not by reading a version string. rustup earned its keep only via per-project rust-toolchain.toml, and no project here has one — so it bought 3.9 GB of unreproducible state and a wrong-arch default.

The question worth keeping: why the migration audit missed it

Adam had audited binaries when he moved to Apple Silicon, and mdfinded for Intel architectures. He was not sloppy. The audit was structurally incapable of seeing this, for three independent reasons — and any one of them alone was fatal.

  1. cargo and rustc on PATH were symlinks to rustup, a single arm64 Mach-O. Every binary on PATH and in the store genuinely was ARM. An inspection-based audit returns a clean bill of health and is correct.
  2. The Intel code was never on PATH. It sat in ~/.rustup/toolchains/stable-x86_64-apple-darwin/bin/, reached only at runtime when the arm64 shim read ~/.rustup/settings.toml and exec’d it. The architecture was a line in a config file, not a property of any inspectable binary. Only rustc -vV’s host: field reveals it, and only by running the thing.
  3. mdfind cannot see command-line binaries at all. Spotlight populates kMDItemExecutableArchitectures only for bundles (.app, .service, .framework). For a bare Mach-O CLI binary it records (null) and a dyn.* content type. His query returned 378 hits, 373 of them .apps. It was not a narrow query — it was the wrong index. (Bonus: ~/projects is not Spotlight-indexed at all, 0 files.)

The correct audit is a filesystem walk through file(1), plus asking every shim what it actually runs: rustc -vV | rg host:, node -p process.arch, java -XshowSettings:properties.

The sweep

Ran that scan across 1.85M files. 1,689 Intel Mach-O objects, ~3.3 GB, in three clusters, none of which mdfind could ever have found:

  • ~/Library/jupyterlab-desktop (1.7 GB) — ARM app, Intel bundled Python env. Never launched.
  • ~/Library/Java/JavaVirtualMachines (1.1 GB) — all four JVMs x86_64. /usr/bin/java is a universal stub, so it passes any arch check; it dispatches to a JVM chosen at runtime, and java -XshowSettings reported os.arch = x86_64. The rustup bug wearing a different hat.
  • ~/.pyenv (532 MB) — and this one was a live bug, not just weight. Its shims were on some processes’ PATH ahead of Nix, and they were dead: they exec’d /nix/store/…-pyenv-2.5.3/libexec/pyenv, a garbage-collected store path. Anything inheriting that PATH (the MCP servers, visibly) got a broken python3 shadowing the working Nix one.

Plus nvim’s mason (441 MB, entirely unreferenced since the move to Nix LSPs, holding Intel gopls/rust-analyzer/lua-language-server), gradle caches, an npx cache, Intel venvs and build artifacts. Down to 8 Intel objects, all deliberate: two live Rosetta apps, three inert npm prebuilds with arm64 siblings, three git-tracked artifacts.

What was striking

Every single miss lived in the same blind spot, and I hit it too. A tool that resolves its target at runtime — a rustup shim, a pyenv shim, the java stub, an LSP command string, command -v python3 || exit 0 — is invisible to static inspection by construction. Today that one shape produced: an Intel Rust toolchain, a dead rust-analyzer, a broken python3 shadowing Nix, and (this morning) silently-dropped hook telemetry. Four bugs, one mechanism.

The three tools each have a blind spot, and the union does not cover the space:

  • atuin sees shell history → blind to GUI apps.
  • Spotlight sees bundles → blind to CLI binaries.
  • file sees binaries → blind to which one a shim chooses.

Two mistakes I made

I inferred purpose from a filename. I put amm.app on the delete list as “Ammonite, the Scala REPL — dead now that the JVMs are gone.” Adam pushed back: “hmm i do use amm.app”. On actually reading the bundle, it is com.pg.amm, a Cocoa/WebKit menu-bar agent with zero Java linkage. It never needed a JVM. I had built a whole causal story (needs Java → Java is gone → it’s dead) on top of a guess about three letters.

And my “unused” evidence was measuring nothing. I “verified” VS Code was unused via mdls /Applications/Visual Studio Code.app — which does not exist, because it lives in ~/Applications. The command returned (null) and I read that as “never used” rather than “you asked about a file that isn’t there.” Same shape as the amm error: I accepted a plausible answer without checking that the question was well-formed. Also called ~/.npm/_npx “small” in a plan I asked Adam to approve; it was 894 MB.

The permission classifier blocked my first bulk rm -rf for precisely the right reason — the request named a category, and I had substituted my own list of specific paths. Being forced to enumerate them is what surfaced the amm error before it did damage.

Widening the search: my own scanner had the same disease

Adam asked whether Intel artifacts could be hiding anywhere else. They could — because my scan had three blind spots by construction, and finding them was the most useful part of the day.

Bug 1: xargs splits on whitespace. I piped fd | xargs file, so every path containing a space was mangled and silently dropped. That hid ~/Library/Application Support/** and — fatally — /Applications/Visual Studio Code.app. Fixed with fd -0 | xargs -0. $HOME went 4 → 19 objects; outside $HOME, 343 → 572.

Bug 2: the prefilter. To avoid running file(1) on 1.85M paths I prefiltered on the executable bit or a known extension (.dylib/.so/.node). But a.out, array, cc and listMain are Mach-O with neither — plain-permission, no extension. Sixteen Intel coursework binaries in Google Drive were invisible to every scan until I dropped the prefilter entirely.

Bug 3: I only ever looked in $HOME. Which is where the real find was hiding.

The find: VS Code was running under Rosetta

Two VS Code installs, sharing the same settings and extensions:

  • /Applications/Visual Studio Code.appx86_64 only, 855 MB, running right now, and what code on PATH launched.
  • ~/Applications/Visual Studio Code.app — universal.

So the editor Adam uses daily had been running translated. And worse: the Intel copy auto-updated its extensions at 23:18 that evening, replacing the darwin-arm64 builds with darwin-x64. Once VS Code ran natively, those x64 native modules could not load at all — an arm64 process cannot dlopen an x86_64 Mach-O, there is no Rosetta bridge inside a live process. cpptools, ruff, python and claude-code were silently dead.

Fixing it exposed a nasty CLI behaviour: code --install-extension --force ignores platform mismatch and just says “already installed” when the version matches. The only way through was deleting the extension directory and clearing its entry from extensions.json, or it kept refusing to refetch. All five platform-specific extensions are now darwin-arm64.

Same class again: logi_obs_plugin.so is x86_64, OBS is arm64 — so that Logitech plugin has never once loaded since the migration, and OBS never said a word.

Two more mistakes, and a rm that couldn’t fail

I put amm.app on a delete list as “Ammonite, the Scala REPL — dead now the JVMs are gone.” Adam: “hmm i do use amm.app”. Reading the bundle: com.pg.amm, a Cocoa/WebKit menu-bar agent with zero Java linkage. I had inferred purpose from three letters and built a causal story on it.

And my “VS Code is unused” evidence was measuring nothing — I ran mdls /Applications/Visual Studio Code.app when it lived in ~/Applications, got (null), and read “never used” instead of “no such file”. On that basis I deleted four extensions from an editor he actively uses. Restored them after.

The rm that lied. Adam ran the sudo cleanup twice and reported success; the file was still there both times, with its 2024 mtime untouched. The command was:

sudo rm -rf '/Library/Application\ Support/obs-studio/plugins/logi_obs_plugin/'

Double-escaped — inside single quotes a backslash is literal, so the path had a real \ in it. That path doesn’t exist, and rm -rf exits 0 on a nonexistent path (that is what -f means). Clean exit, no output, nothing deleted. A command that cannot fail loudly, quietly doing nothing — the exact shape of command -v python3 || exit 0, in the tool I was using to clean up after it.

Tally

Cleaned: the Intel VS Code (855 MB), Camera Settings.app (72 MB, legacy Logitech — Options+ is universal and does its job), the dead OBS plugin, Flash Player remnants (14 MB), 20 coursework build artifacts in Google Drive (sources, reports and the course-provided RVS046.mac simulator preserved), plus the earlier ~6 GB. ~7 GB total.

Left deliberately: amm.app (live, Intel-only, needs a vendor ARM build), inert darwin-x64 prebuilds in npm/pnpm/Obsidian that sit beside the arm64 ones Node actually loads, browser DRM plugins, and — outside $HOME — 473 objects that are all normal vendor content (Xcode’s Intel toolchains and simulator runtimes, printer/scanner drivers, Rosetta itself, and Homebrew’s deliberately Intel test fixtures).