vim-plugin-manager: argument injection hidden in a .gitmodules
vim-plugin-manager (repo ) shipped v1.0.0 in April 2025 with a design I was proud of: git submodules, native packages, the vimrc as source of truth. The sixteen months since (v1.0.0 to v2.2.10) were the part I was not proud of yet and that is the interesting part: the CHANGELOG reads like a course in how a tool that trusts its config gets humbled. Three stories from the saga.
The argument injection that lived in a config file
The scariest finding of the series (#9, rated HIGH, proven arbitrary command execution): submodule.<path>.branch from .gitmodules was passed straight into git pull origin <branch>. A branch name starting with a dash is parsed by git as an option and git has an option that executes a command: --upload-pack=<cmd>. Demonstrated locally with git 2.43, shell quoting notwithstanding.
Think about who writes .gitmodules: sometimes you, but also the plugins themselves when you add them and every third-party repo you ever cloned into the config. A hostile plugin repository could name its submodule branch --upload-pack=... and wait. The fix is the boring kind: sanitize_branch, first character may not be -, charset ^[A-Za-z0-9._/-]+$, applied at the .gitmodules read and at git submodule add -b, refused values warn and fall back. The same sprint caught the siblings: remote names interpolated unescaped into git remote add (shell operators = arbitrary command execution), module names breaking out of a compound commit command and a general rule was extracted: validate every untrusted value before it reaches git, the filesystem, or the logs (#10).
A plugin manager is a package manager. Package managers are trust boundaries. It took a proven arbitrary command execution to make that sentence stop being theoretical.
The subprocess budget, pinned forever
The performance work has a shape I now want everywhere: collect_status_local went from 9 git subprocesses per module to 7 (6 when a branch is declared in .gitmodules), by reusing the mtime-cached module parse and collapsing a sequential upstream/show-ref cascade into one git for-each-ref. The 2-subprocess saving is trivia; the regression guard is not: tests/perf.vader pins the budget as a test, so nobody can quietly reintroduce the ninth call. The health check also went from a 200-line sequential function to a table of check Funcrefs, pinned by a characterization test that asserts the report output is unchanged.
Performance work that ships without a budget is performance work you will redo next quarter. The budget is the feature.
The details that were lying
Two bugs from the “it works, mostly” shelf:
- Detached plugins reported as up-to-date. A plugin in detached HEAD state cannot be pulled, but status rendered it as current. Now
:PluginManager statusreportsDetachedwith an actionable re-pin hint: the nearest reachable tag, computed offline. A status display that hides the decision the user must make is decoration. - Stale tag mirrors poisoned every update. Diagnosed on a real setup: 12 stale v1.x/v2.1.x tag mirrors made every
updateof the affected module fail with “Fetch failed”. Local tags in a plugin submodule are mirrors of upstream, never local creations, so the fix aligns the trust level:git fetch --tags --force origin, refreshed mirrors, honest error only when it is real.
The testing lesson, repeated until it stuck
Headless vim -es never runs the event loop, which makes it perfect for testing code that does not need one and useless for testing async jobs. The CI runs the real-async suite under script -qec (a pty) and the distro matrix (AlmaLinux 9/10, Debian Bookworm/Trixie, Ubuntu 24.04/26.04, Arch, Gentoo non-blocking) exists because “Vim 8.2+” means different patch levels with different bugs. Around 4,300 lines of Vader tests against 5,600 of code, plus a dead-code purge in the CHANGELOG that reads like spring cleaning: never-read state fields, highlight keywords no code emits, an unreachable branch of the sidebar updater.
The dogfood loop held through all of it: MyVim , my entire editor config, runs on this manager as a plugin and it was the first repository every one of these security fixes was thrown against.
When the tool turns on you
- Config files from third parties are untrusted input.
.gitmodulesis shipped by every plugin you install; parse it like a network payload. - A perf budget belongs in the test suite, or the optimization is a story you tell, not a property you own.
- Status displays must surface decisions, not comfort. “Up-to-date” for a detached plugin is a lie with a green checkmark.
- Local tags are mirrors, so give them the mirror’s permissions. Half of the weird git failures in this saga were trust-level mismatches.
[edit: 2026] The Ansible implementation of all this: the neovim and vim roles.