~/research/2026-08-24
Hardening Omarchy's plugin installer against git transport-helper URLs
tl;dr
Omarchy's
omarchy-plugin-add command clones a user-supplied git URL. It validated
the plugin after cloning, but not the URL before cloning — so the only
thing standing between a hostile URL like ext::sh -c <cmd> and command
execution at clone time was a git configuration default the project doesn't control.
The sibling command omarchy-theme-install already guarded against exactly
this; my patch ports that guard to plugin-add and adds a regression test
that proves both the rejections and that every legitimate URL shape still clones.
No live RCE — this is defense-in-depth, and it's now upstream.
background: what plugin-add does
Omarchy is DHH's opinionated Arch-based Linux distribution, developed on GitHub
under the basecamp organization. Its plugin
system lets users extend the desktop by installing plugins straight from a git
repository: omarchy-plugin-add <url> takes a URL (from the command line
or from an interactive gum input prompt), runs git clone, and then
validates and enables the plugin.
That ordering is the interesting part. All of the project's plugin validation
happens after the clone. Whatever git clone itself does with the URL
happens first — with no checks in front of it.
the attack surface: git transport helpers
A git URL is not just an address. Git supports remote transport helpers: a URL
of the form <helper>::<address> tells git to hand the connection to an
external helper program. Two forms matter here:
-
ext::<command>— the "external" helper literally runs the given command to reach the remote.git clone "ext::sh -c 'touch /tmp/pwned'"is command execution at clone time, by design. -
fd::<n>— the file-descriptor helper; less dramatic, but equally "this is not a repository address".
There is a second, related class: URLs that start with a dash. An argument like
--upload-pack=<cmd> isn't a URL at all — if it reaches a
git clone invocation in argument position, git parses it as an option, and
--upload-pack in particular names a command to run. This is the classic
argument-injection shape that has bitten many tools that pass user input to CLIs.
why this was not a live RCE
Git gates transport helpers behind protocol.ext.allow, and on a stock
Omarchy system that default holds — there is no protocol.* override anywhere
in the distribution, so an ext:: clone attempt dies before the helper runs.
I verified that before writing a single line: no default-config exploit, no
vulnerability report, no CVE theater.
Two more things sharpened the case. First, upstream had already accepted this exact
reasoning once: PR #7884
("Stop an installed theme from running code") added precisely this guard to
omarchy-theme-install — but not to plugin-add, leaving the two
sibling commands inconsistent. Second, a proposal
(#7622)
would let plugin URLs arrive from web pages, moving the input source further into
untrusted territory.
the guard
The fix is deliberately small: apply the same guard theme-install uses,
after both input paths (argv and the interactive prompt) have produced the final
$url, and before git clone runs:
# git reads a leading dash as an option, and `<helper>::<address>` as a remote
# helper to run at clone time. Reject both so an untrusted URL cannot smuggle a
# transport helper that executes before the plugin is validated or enabled. An
# scp-style IPv6 host such as git@[2001:db8::1]:org/repo.git carries `::` too and
# must still clone.
if [[ $url == -* || $url =~ ^[A-Za-z0-9][A-Za-z0-9+.-]*:: ]]; then
fail "'$url' names a git option or transport helper, not a repository."
fi
The regex is the part worth reading twice. It rejects a string that starts with
a scheme-shaped name ([A-Za-z0-9][A-Za-z0-9+.-]*) followed by
:: — which is exactly the transport-helper grammar. It deliberately does
not reject every URL containing ::, because legitimate URLs can carry
one: an scp-style IPv6 address like git@[2001:db8::1]:org/repo.git has
:: inside the brackets — but the characters before it (@,
[) can't appear in a helper name, so the anchor at the start of the string
lets it through. Naive "contains ::" filters break IPv6 users; this one doesn't.
What still clones, verified by test: https://…, scp-style
git@host:org/repo.git, ssh://…, and the IPv6 form above.
(Token-auth HTTPS URLs pass the guard too — same https:// shape — but
they're not among the test's fixtures.) What gets refused: ext::…,
fd::…, and anything shaped like an option.
proving it: the regression test
A guard like this is cheap to write and easy to break later, so most of the patch is
test. The suite runs with no network and no real git: a stubbed
git drops a marker file when its clone subcommand is reached.
That turns "did the guard fire?" into a crisp, observable fact — if the marker exists,
the URL got past the guard; if not, it was rejected first.
-
ext::sh -c …andfd::17are rejected, with the guard's own error message, and never reach clone. -
Option-shaped input on argv (
-oProxyCommand=x,--upload-pack=x) is refused before clone — interestingly by the option parser, not the guard, since argv dashes die as "unknown add option" first. -
The guard's leading-dash arm is therefore only reachable through the interactive
gum inputprompt. The test drives that path for real: agumstub answers the prompt with an option-shaped value, and the whole thing runs on a pseudo-terminal via util-linuxscript -qec, because the interactive path requires a TTY on stdin and stdout. - And the other direction: all the legitimate URL shapes above must reach the (stubbed) clone — a guard that overblocks is a bug too.
takeaways
-
Sibling commands drift. A guard added to
theme-installnever made it toplugin-add, though both clone untrusted URLs. When you fix a class of bug, grep for the pattern's siblings. - A safety property you don't own is a liability. "git's default stops it" is true until someone's config, distro, or a future git release says otherwise. Enforce your invariants in your own code.
- Honest severity wins. This was pitched — and merged — as defense-in-depth, not as an RCE. Saying precisely what something is not is part of the research.
- Test both directions. The rejection cases and the must-still-work cases (hi, IPv6) — otherwise the guard either rots or overblocks.
Full patch and discussion: basecamp/omarchy #8067 — merged 2026-08-25.
< cd ~/basti.net