omarchy-plugin-vet
I run Omarchy as my daily driver — an Arch + Hyprland distribution with a gorgeous Quickshell-based status bar and a growing marketplace of third-party “shell plugins”. The plugins are lovely. The security model around them is, frankly, something every Omarchy user should understand before they install a single one.
This post is about a small helper I wrote called omarchy-plugin-vet. It runs a battery of supply-chain and code signals against a plugin before you enable it, so you can make an informed trust decision instead of blindly omarchy plugin add-ing whatever looks shiny. I’ll cover the threat model, what the script checks, what it deliberately does not claim to do, how it slots into the install workflow, the full source, and a line-by-line walkthrough.
The threat model: why Omarchy plugins are an attack vector
Start with what Omarchy itself says. The plugin development guide states plainly:
Plugins run unsandboxed with your user permissions.
and
The marketplace validates listings, not plugin security.
That second quote comes from the publishing guide. Read those two sentences together and the picture is clear: when you install a third-party Omarchy plugin, you are executing arbitrary QML/JavaScript inside the long-running Quickshell process, with full access to everything your user account can touch. That means:
~/.ssh/— your private keys andconfig~/.config/— browser profiles, tokens,gitcredentials, cloud CLI configs- anything in
$HOMEyour user can read or write - the ability to make outbound network connections and exfiltrate data
omarchy plugin validate <dir> — the built-in check — only verifies the manifest schema: required fields are present, entry-point files exist, the ID isn’t a spoofed omarchy.*, and the folder contains no symlinks. That’s hygiene, not security. It will not stop a plugin that, on first run, reads your SSH keys and POSTs them to a server. And omarchy plugin update re-pulls HEAD from the upstream repo, so a repository that is compromised after you first reviewed it silently changes the code that runs on your machine.
This is not a criticism of Omarchy — the sandboxing trade-off is a deliberate one (you can’t sandbox a bar widget that has to launch your browser and read your calendar). But it means the trust decision is yours to make, and you need signal to make it. That’s the gap omarchy-plugin-vet tries to fill.
What omarchy-plugin-vet does
It is a single Bash script that takes one argument — either a git URL (to vet a plugin before installing it) or an installed plugin id (to vet something already in ~/.config/omarchy/plugins/) — and prints a report made of labelled sections, each tagging its findings as [PASS], [WARN], [INFO], or [FAIL]. It exits 0 when no high-risk findings are present and 2 when there are.
It runs seven checks:
| Check | What it looks at |
|---|---|
| git metadata | last commit date, commit count, author count, remote URL |
| OpenSSF Scorecard | branch protection, dangerous workflows, binary artifacts, maintenance, code review, pinned deps, signed releases, token permissions, known vulnerabilities |
| binary artifacts | checked-in executable or binary files (non-reviewable code) |
| dangerous primitives | QML/JS that makes network calls, shells out, or uses eval-style dynamic code |
| manifest | parses manifest.json, flags a missing licence, runs omarchy plugin validate |
| qmllint | static lint of the .qml files against the Omarchy shell imports |
| osv-scanner | known-CVE scan of any bundled dependencies |
Two of those (Scorecard CLI and osv-scanner) are optional — the script degrades gracefully and tells you how to install them. The rest run on a stock Omarchy box with git, jq, curl, file, and omarchy on PATH.
How it fits the plugin install workflow
This is the question I get asked most, so let’s answer it up front:
Do I need to do something, or will it work automatically when I install a plugin?
You need to run it yourself. It is a manual gate, not an automatic wrapper. omarchy plugin add is unchanged — it still clones and enables a plugin exactly as before. omarchy-plugin-vet is an opt-in pre-flight check you run before you invoke omarchy plugin add.
The intended workflow is:
- You spot a plugin on plugins.omarchy.org and copy its git URL.
- Before installing, you run:
omarchy-plugin-vet https://github.com/owner/their-plugin.gitThe script does a shallow clone into a temporary directory, vets it, prints the report, and cleans up. - You read the report. If it’s clean and you’ve eyeballed the flagged primitives, you install for real:
omarchy plugin add https://github.com/owner/their-plugin.git --enable - Optionally, you pin the plugin to the commit you just reviewed, so a later upstream compromise can’t silently change what runs:
git -C ~/.config/omarchy/plugins/<plugin-id> checkout <sha>
I chose a manual gate on purpose. An automatic wrapper that blocked omarchy plugin add would be annoying for trusted plugins (the built-in omarchy.* ones, or ones you’ve already vetted), and an automatic wrapper that silently passed everything would defeat the point. A human reads the report and decides. The script’s job is to surface the signal, not to make the decision for you.
Where it lives in my dotfiles
I keep omarchy-plugin-vet in my omarchy-setup repository under files/.local/bin/, and my apply.sh bootstrap copies it (alongside the omarchy-confirm-close helper) into ~/.local/bin as part of the keybindings category. Because ~/.local/bin is on PATH, that means after running my setup the command is just… there. No extra install step, no sudo. The relevant snippet from apply_keybindings() in apply.sh:
# omarchy-plugin-vet: standalone plugin security vetting helper. Not
# feature-gated; chmod in case git lost the exec bit on clone.
backup_and_copy_file ".local/bin/omarchy-plugin-vet"
chmod +x "$HOME/.local/bin/omarchy-plugin-vet"
What it checks, and what each check protects against
git metadata — surfaces repos that were created last Tuesday and have one commit. A brand-new, single-author repository is the classic profile of a throwaway account pushing malware, or a typosquat of a popular plugin id. It also flags repos with no commits in over a year, which may be unmaintained and therefore won’t get security fixes. (Caveat: when vetting via a git URL the script uses --depth 1, so history is limited — the script tells you this and points you at the GitHub repo for full metadata.)
OpenSSF Scorecard — this is the heavyweight. OpenSSF Scorecard runs 18 automated checks against a repository’s security practices and scores each 0–10. The script asks for the subset that matters most for “should I run this stranger’s code”:
- Branch-Protection — is
maingated by review and status checks, or can anyone push straight to it? No branch protection + a single maintainer = one compromised account away from malware. - Dangerous-Workflow — does the repo’s GitHub Actions contain privileged patterns that run untrusted code? (This is the check that, in my script, would contribute to a
FAIL.) - Binary-Artifacts — are there checked-in binaries? Non-reviewable code.
- Maintained — recent commit and issue activity?
- Code-Review — is review required before merge?
- Pinned-Dependencies — are CI deps pinned by hash, or floating tags?
- Signed-Releases — are releases cryptographically signed?
- Token-Permissions — do workflow tokens follow least privilege?
- Vulnerabilities — does the repo have known unfixed CVEs (via OSV)?
If the scorecard CLI is installed it runs locally (using your GITHUB_AUTH_TOKEN, or gh auth token as a fallback). If not, it falls back to the pre-computed REST API at api.scorecard.dev for popular repos. If neither yields a score, it prints a direct web-viewer link so you can check by eye. That three-tier fallback means the script is useful even on a machine with nothing extra installed.
binary artifacts — walks the plugin tree, finds every executable file, and uses file(1) to classify it. Anything that isn’t recognisably text/JSON/script gets flagged. This protects against the most obvious “ship a precompiled binary in a QML plugin” trick, which omarchy plugin validate does not catch. This is a hard [FAIL] in my script.
dangerous primitives — greps the QML/JS for the primitives that warrant a human look: XMLHttpRequest, .fetch(, Qt.openUrlExternally, Process, hl.exec_cmd, exec_cmd, createQmlObject, eval(, Function(, atob/btoa, and sh -c/bash -c. A plugin that opens a URL when you click it legitimately uses Qt.openUrlExternally; a plugin that quietly fires XMLHttpRequest from a service kind does not. The script can’t tell the difference — it surfaces every hit for you to judge. This is the check that turns “the marketplace validated it” into “I have actually looked at the dangerous lines.”
manifest — parses manifest.json with jq, prints id/name/author/version/licence/kinds, warns on a missing licence (a signal of fly-by-night code), and runs omarchy plugin validate so you get Omarchy’s own schema check too. A missing manifest is a hard [FAIL].
qmllint — runs Qt’s QML linter with the Omarchy shell as the import path. This is not a security check; it catches broken imports and syntax that might make a plugin misbehave or crash the shell. It’s the same step Omarchy’s own dev guide recommends before publishing.
osv-scanner — if installed, scans the plugin’s bundled dependencies against the OSV vulnerability database. Most QML plugins have few or no deps, so this is often quiet, but for plugins that vendor Node or Python artefacts it’s valuable.
What it does not do (be honest about this)
I want to be very clear, because overselling a security tool is worse than not having one:
- It cannot detect a subtly malicious but well-packaged plugin. A plugin that reads
~/.ssh/id_ed25519and exfiltrates it via a carefully obfuscatedXMLHttpRequestwill pass every check if the repo has branch protection and the author used a real-looking id. Scorecard measures process hygiene, not intent. The primitives grep only catches the obvious cases — anything obfuscated slips through. - It does not sandbox anything. There is no per-plugin sandbox in Omarchy (the docs explicitly forbid spinning a second Quickshell process), and this script doesn’t change that. The blast radius of a malicious plugin is still “your entire user account”.
- It is not a substitute for reading the code. It is a triage tool that tells you where to look and whether to bother. For a plugin you’ll run on the machine that holds your SSH keys and tokens, you should still read the QML.
- It does not continuously monitor. It’s a point-in-time check at install (or update) time. A repo compromised after you vetted it is not caught unless you re-run the vet — which is exactly why the recommendation section nags you to pin the commit and avoid
omarchy plugin updatefor untrusted plugins. - Scorecard is GitHub-only. If a plugin’s remote isn’t on GitHub, the Scorecard section gracefully reports “no GitHub remote detected” and moves on. You lose that signal for self-hosted Gitea plugins.
- It depends on the optional tools for the deepest checks. Without
scorecardandosv-scannerinstalled you get the REST fallback and a skip respectively. Install them for full coverage.
Why you should use it
Because the alternative is omarchy plugin add <url> --enable and hoping. The marketplace does not vouch for plugin security; omarchy plugin validate does not inspect code; and the plugins themselves run unsandboxed with your user permissions. omarchy-plugin-vet takes about ten seconds to run, costs nothing, and turns an opaque “trust me” into a labelled report you can actually reason about. It will not make you immune to a determined attacker, but it will catch the lazy ones — the fresh single-author repo, the checked-in binary, the unexplained XMLHttpRequest in a clock widget — and it will point you at the exact lines to review for everything else.
Use it as a gate at install time. Pin the commit afterwards. Re-run it on update. Don’t run untrusted plugins on the machine that holds your secrets, or accept that risk with your eyes open.
Setup
1. Get the script onto your PATH
If you use my omarchy-setup repo, it’s already wired in — running ./apply.sh --only keybindings copies both omarchy-confirm-close and omarchy-plugin-vet into ~/.local/bin and makes them executable. Done.
If you’re doing it by hand, drop the script at ~/.local/bin/omarchy-plugin-vet and make it executable:
install -Dm755 omarchy-plugin-vet ~/.local/bin/omarchy-plugin-vet
Ensure ~/.local/bin is on your PATH (it is by default on Omarchy).
2. (Optional, recommended) install the two extra tools for full coverage
# OpenSSF Scorecard CLI — supply-chain posture scores
go install github.com/ossf/scorecard/v5/cmd/scorecard@latest
# OSV-Scanner — known-vulnerability scanning of bundled deps
go install github.com/google/osv-scanner/v2/cmd/osv-scanner@latest
You’ll need go (pacman -S go or omarchy pkg add go). Scorecard works best with a GitHub token — export GITHUB_AUTH_TOKEN, or just have gh authenticated and the script will call gh auth token for you.
3. Use it
# Vet BEFORE installing (shallow-clones to a temp dir, cleans up afterwards)
omarchy-plugin-vet https://github.com/Woogy7/omarchy-workspace-switcher.git
# Vet something already installed
omarchy-plugin-vet io.github.woogy7.workspaces
# Help
omarchy-plugin-vet --help
Read the report. If it’s clean and you’ve eyeballed any flagged primitives, install for real and pin:
omarchy plugin add https://github.com/Woogy7/omarchy-workspace-switcher.git --enable
git -C ~/.config/omarchy/plugins/io.github.woogy7.workspaces checkout <sha>
The full script
#!/usr/bin/env bash
# omarchy-plugin-vet — security vetting for an Omarchy shell plugin.
#
# Omarchy plugins are QML/JS that run UNSANDBOXED with your user permissions in the
# shared Quickshell process, and the plugin marketplace "validates listings, not
# plugin security". This script surfaces supply-chain and code signals so you can
# make an informed trust decision. It does NOT prove a plugin is safe — a subtly
# malicious but well-packaged plugin can pass every check. Use it to triage, then
# read the code yourself.
#
# Usage:
# omarchy-plugin-vet <git-url> vet a remote plugin BEFORE installing (shallow clone to a temp dir)
# omarchy-plugin-vet <plugin-id> vet an already-installed plugin in ~/.config/omarchy/plugins/<id>
# omarchy-plugin-vet --help
#
# Checks (each prints PASS/WARN/INFO/FAIL):
# git-meta repo age, last commit, author count, remote URL
# scorecard OpenSSF Scorecard (CLI if installed, else REST API, else a web link)
# binaries checked-in executable/binary artifacts
# primitives QML/JS network/exec/eval primitives that warrant human review
# manifest manifest.json summary + omarchy plugin validate
# qmllint qmllint against the Omarchy shell imports (if available)
# osv osv-scanner dependency CVE scan (if installed)
#
# Exit codes: 0 = no high-risk findings (warnings may remain; always read the report)
# 2 = high-risk finding (checked-in binary artifact, missing manifest,
# or Scorecard Dangerous-Workflow score of 1)
#
# Install more of the optional tools for fuller coverage:
# go install github.com/ossf/scorecard/v5/cmd/scorecard@latest
# go install github.com/google/osv-scanner/v2/cmd/osv-scanner@latest
set -o pipefail
PLUGIN_DIR=""
REMOTE_URL=""
TMP_CLONE=""
cleanup() { [[ -n "$TMP_CLONE" && -d "$TMP_CLONE" ]] && rm -rf "$TMP_CLONE"; }
trap cleanup EXIT
# ---- output helpers ----
if [[ -t 1 ]]; then
C_PASS=$'\033[32m'; C_WARN=$'\033[33m'; C_FAIL=$'\033[31m'; C_INFO=$'\033[36m'; C_RST=$'\033[0m'
else
C_PASS=""; C_WARN=""; C_FAIL=""; C_INFO=""; C_RST=""
fi
pass() { printf '%s[PASS]%s %s\n' "$C_PASS" "$C_RST" "$1"; }
warn() { printf '%s[WARN]%s %s\n' "$C_WARN" "$C_RST" "$1"; }
fail() { printf '%s[FAIL]%s %s\n' "$C_FAIL" "$C_RST" "$1"; }
info() { printf '%s[INFO]%s %s\n' "$C_INFO" "$C_RST" "$1"; }
section() { printf '\n%s=== %s ===%s\n' "$C_INFO" "$1" "$C_RST"; }
high_risk=0
# ---- arg parsing ----
case "${1:-}" in
-h|--help)
sed -n '2,26p' "$0"; exit 0 ;;
"")
echo "Usage: omarchy-plugin-vet <git-url|plugin-id>" >&2
exit 1 ;;
esac
ARG="$1"
if [[ "$ARG" == *://* || "$ARG" == *.git || "$ARG" == git@* ]]; then
REMOTE_URL="$ARG"
TMP_CLONE="$(mktemp -d)"
info "Shallow-cloning $REMOTE_URL -> $TMP_CLONE ..."
if git clone --depth 1 "$REMOTE_URL" "$TMP_CLONE" >/dev/null 2>&1; then
PLUGIN_DIR="$TMP_CLONE"
else
git clone --depth 1 "$REMOTE_URL" "$TMP_CLONE" 2>&1 | sed 's/^/ /'
fail "git clone failed"; exit 2
fi
else
PLUGIN_DIR="$HOME/.config/omarchy/plugins/$ARG"
if [[ ! -d "$PLUGIN_DIR" ]]; then
fail "plugin not found: $PLUGIN_DIR"
echo " pass a git URL to vet before installing, or an installed plugin id" >&2
exit 1
fi
info "Vetting installed plugin: $PLUGIN_DIR"
REMOTE_URL="$(git -C "$PLUGIN_DIR" remote get-url origin 2>/dev/null || true)"
[[ -n "$REMOTE_URL" ]] && info "Remote: $REMOTE_URL"
fi
# Extract github.com/<owner>/<repo> from a remote URL (Scorecard covers GitHub only).
owner_repo=""
if [[ -n "$REMOTE_URL" ]]; then
u="${REMOTE_URL%.git}"
u="${u#*github.com}"
u="${u#:}"
u="${u#/}"
owner_repo="$u"
fi
# ---- git metadata ----
section "git metadata"
if git -C "$PLUGIN_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
last_date="$(git -C "$PLUGIN_DIR" log -1 --format=%cd --date=short 2>/dev/null)"
commits="$(git -C "$PLUGIN_DIR" rev-list --count HEAD 2>/dev/null || echo "?")"
authors="$(git -C "$PLUGIN_DIR" shortlog -sne HEAD 2>/dev/null | wc -l | tr -d ' ')"
if [[ -n "$last_date" ]]; then
now_s=$(date +%s); then_s=$(date -d "$last_date" +%s 2>/dev/null || echo "$now_s")
age_days=$(( (now_s - then_s) / 86400 ))
else
age_days="?"
fi
info "last commit: ${last_date:-?} (${age_days} days ago), ${commits} commits, ${authors} author(s)"
[[ -n "$TMP_CLONE" ]] && info "(shallow clone — history limited; vet the GitHub repo for full metadata)"
[[ "${authors:-0}" =~ ^[0-9]+$ ]] && (( authors <= 1 )) && warn "single-author repo"
[[ "${age_days:-999}" =~ ^[0-9]+$ ]] && (( age_days > 365 )) && warn "no commits in over a year (possibly unmaintained)"
else
info "(not a git work tree)"
fi
# ---- OpenSSF Scorecard ----
section "OpenSSF Scorecard"
if [[ -z "$owner_repo" ]]; then
info "no GitHub remote detected (Scorecard covers GitHub repos only)"
elif command -v scorecard >/dev/null 2>&1; then
if [[ -z "${GITHUB_AUTH_TOKEN:-}" ]] && command -v gh >/dev/null 2>&1; then
GITHUB_AUTH_TOKEN="$(gh auth token 2>/dev/null || true)"; export GITHUB_AUTH_TOKEN
fi
[[ -z "${GITHUB_AUTH_TOKEN:-}" ]] && warn "GITHUB_AUTH_TOKEN unset; scorecard may hit rate limits"
scorecard --repo="github.com/$owner_repo" \
--checks=Branch-Protection,Dangerous-Workflow,Binary-Artifacts,Maintained,Code-Review,Pinned-Dependencies,Signed-Releases,Token-Permissions,Vulnerabilities \
--format=table 2>&1 | sed 's/^/ /' || warn "scorecard run failed"
else
resp="$(curl -fsS --max-time 15 "https://api.scorecard.dev/v2/projects/github.com/$owner_repo" 2>/dev/null || true)"
if [[ -n "$resp" ]]; then
info "pre-computed Scorecard (api.scorecard.dev):"
printf '%s\n' "$resp" | jq -r '.checks[]? | " \(.name): \(.score)/10"' 2>/dev/null | head -40 \
|| printf '%s\n' "$resp" | head -40 | sed 's/^/ /'
else
info "scorecard CLI not installed and no pre-computed score available"
info "View online: https://scorecard.dev/viewer/?uri=github.com/$owner_repo"
info "Install CLI: go install github.com/ossf/scorecard/v5/cmd/scorecard@latest"
fi
fi
# ---- binary / executable artifacts ----
section "binary artifacts"
bins=0
while IFS= read -r -d '' f; do
[[ -x "$f" ]] || continue
if ! file "$f" 2>/dev/null | grep -qiE 'text|json|script|ascii|utf-8|xml|unicode'; then
printf ' %s\n' "${f#$PLUGIN_DIR/}"
bins=$((bins + 1))
fi
done < <(find "$PLUGIN_DIR" -type f -not -path '*/.git/*' -print0 2>/dev/null)
if (( bins == 0 )); then
pass "no executable/binary artifacts found"
else
fail "found $bins binary/executable file(s) — non-reviewable code, review before trusting"
high_risk=1
fi
# ---- dangerous primitives (manual review) ----
section "dangerous primitives (review manually)"
matches="$(grep -rEn \
'XMLHttpRequest|Qt\.openUrlExternally|\.fetch\(|\bProcess\b|hl\.exec_cmd|exec_cmd|createQmlObject|\beval\(|\bFunction\(|\batob\b|\bbtoa\b|sh -c|bash -c' \
--include='*.qml' --include='*.js' --include='*.mjs' --include='*.json' \
"$PLUGIN_DIR" 2>/dev/null | grep -v '/.git/' | head -30)"
if [[ -n "$matches" ]]; then
warn "network/exec/eval primitives found — inspect each use:"
printf '%s\n' "$matches" | sed 's/^/ /'
else
pass "no obvious network/exec/eval primitives in QML/JS"
fi
# ---- manifest + omarchy validate ----
section "manifest"
mf="$PLUGIN_DIR/manifest.json"
if [[ -f "$mf" ]]; then
if jq -r '" id: \(.id)\n name: \(.name)\n author: \(.author // "?")\n version: \(.version // "?")\n license: \(.license // "(none declared)")\n kinds: \(.kinds | join(", "))"' "$mf" 2>/dev/null; then
[[ -z "$(jq -r '.license // empty' "$mf" 2>/dev/null)" ]] && warn "no license declared"
else
warn "manifest.json is not valid JSON:"; sed 's/^/ /' "$mf"
fi
else
fail "manifest.json missing"
high_risk=1
fi
if command -v omarchy >/dev/null 2>&1; then
if omarchy plugin validate "$PLUGIN_DIR" >/dev/null 2>&1; then
pass "omarchy plugin validate OK"
else
warn "omarchy plugin validate reported issues:"
omarchy plugin validate "$PLUGIN_DIR" 2>&1 | sed 's/^/ /'
fi
else
info "omarchy CLI not on PATH — skipped omarchy plugin validate"
fi
# ---- qmllint ----
section "qmllint"
if command -v qmllint >/dev/null 2>&1; then
shellpath="${OMARCHY_PATH:-/usr/share/omarchy}/shell"
mapfile -d '' qml_files < <(find "$PLUGIN_DIR" -type f -name '*.qml' -not -path '*/.git/*' -print0 2>/dev/null)
if (( ${#qml_files[@]} == 0 )); then
info "no .qml files to lint"
else
out="$(qmllint -I "$shellpath" "${qml_files[@]}" 2>&1 || true)"
if [[ -z "$out" ]]; then
pass "qmllint clean (${#qml_files[@]} file(s))"
else
warn "qmllint findings:"; printf '%s\n' "$out" | sed 's/^/ /'
fi
fi
else
info "qmllint not installed — skipped"
fi
# ---- osv-scanner ----
section "osv-scanner (dependencies)"
if command -v osv-scanner >/dev/null 2>&1; then
tmp="$(mktemp)"
if osv-scanner scan -r "$PLUGIN_DIR" >"$tmp" 2>&1; then
pass "no known vulnerabilities found"
else
warn "osv-scanner findings (or scan error):"; sed 's/^/ /' "$tmp"
fi
rm -f "$tmp"
else
info "osv-scanner not installed — skipped"
info "Install: go install github.com/google/osv-scanner/v2/cmd/osv-scanner@latest"
fi
# ---- recommendation ----
section "recommendation"
if (( high_risk )); then
fail "high-risk findings above — do NOT enable until they are resolved"
exit 2
fi
info "No high-risk findings. This is NOT a guarantee of safety."
info "Omarchy plugins run unsandboxed with your user permissions (can read ~/.ssh, tokens, etc)."
info "Next steps if you choose to trust it:"
if [[ -n "$REMOTE_URL" && -n "$TMP_CLONE" ]]; then
info " install: omarchy plugin add $REMOTE_URL --enable"
fi
info " pin to reviewed commit: git -C ~/.config/omarchy/plugins/<id> checkout <sha>"
info " avoid 'omarchy plugin update' for untrusted plugins; re-review diffs on update"
exit 0
Line-by-line walkthrough
The header and safety settings
#!/usr/bin/env bash
Standard shebang — use whatever bash is on PATH, not a hardcoded path.
set -o pipefail
This is the important one. Without pipefail, a pipeline like git … | sed … returns the exit code of sed (the last command), masking a git failure. With it, the pipeline fails if any stage fails, so I don’t silently act on bad data. I deliberately do not set -e (errexit) here, because the script is a pile of independent checks — one failing grep shouldn’t abort the whole report.
PLUGIN_DIR=""
REMOTE_URL=""
TMP_CLONE=""
Three state variables. PLUGIN_DIR is what every check actually inspects. REMOTE_URL feeds Scorecard. TMP_CLONE remembers whether we made a temp directory we need to clean up.
cleanup() { [[ -n "$TMP_CLONE" && -d "$TMP_CLONE" ]] && rm -rf "$TMP_CLONE"; }
trap cleanup EXIT
Register a cleanup function on EXIT. If we shallow-cloned a repo to vet it, the temp directory is removed however we exit — success, failure, or Ctrl-C. This matters: you don’t want vetted-but-not-installed plugin clones piling up in /tmp.
Output helpers
if [[ -t 1 ]]; then
C_PASS=$'\033[32m'; C_WARN=$'\033[33m'; C_FAIL=$'\033[31m'; C_INFO=$'\033[36m'; C_RST=$'\033[0m'
else
C_PASS=""; C_WARN=""; C_FAIL=""; C_INFO=""; C_RST=""
fi
If stdout is a terminal (-t 1), enable ANSI colours; otherwise (piped to a file or another command) emit plain text so logs stay clean. This is why the report is readable both on screen and in a pipe.
pass() { printf '%s[PASS]%s %s\n' "$C_PASS" "$C_RST" "$1"; }
warn() { printf '%s[WARN]%s %s\n' "$C_WARN" "$C_RST" "$1"; }
fail() { printf '%s[FAIL]%s %s\n' "$C_FAIL" "$C_RST" "$1"; }
info() { printf '%s[INFO]%s %s\n' "$C_INFO" "$C_RST" "$1"; }
section() { printf '\n%s=== %s ===%s\n' "$C_INFO" "$1" "$C_RST"; }
high_risk=0
Five tiny printers, each prefixing its label in the right colour, plus a section header. high_risk is the flag that decides the final exit code — any check that finds a genuine danger sets it to 1.
Argument parsing and the two input modes
case "${1:-}" in
-h|--help)
sed -n '2,26p' "$0"; exit 0 ;;
--help prints lines 2–26 of the script itself — i.e. the comment header — which doubles as a man page. Cheap, no separate help text to keep in sync.
"")
echo "Usage: omarchy-plugin-vet <git-url|plugin-id>" >&2
exit 1 ;;
esac
ARG="$1"
No args is a usage error. Otherwise grab the argument.
if [[ "$ARG" == *://* || "$ARG" == *.git || "$ARG" == git@* ]]; then
This is the heuristic that decides “is this a git URL or a plugin id?” It matches https://…, ssh://…, …git, and git@host:… forms. If it looks like a URL, we treat it as a remote to clone; otherwise it’s an installed plugin id.
REMOTE_URL="$ARG"
TMP_CLONE="$(mktemp -d)"
info "Shallow-cloning $REMOTE_URL -> $TMP_CLONE ..."
if git clone --depth 1 "$REMOTE_URL" "$TMP_CLONE" >/dev/null 2>&1; then
PLUGIN_DIR="$TMP_CLONE"
else
git clone --depth 1 "$REMOTE_URL" "$TMP_CLONE" 2>&1 | sed 's/^/ /'
fail "git clone failed"; exit 2
fi
The “vet before install” path. mktemp -d makes a private temp dir. git clone --depth 1 grabs only the latest commit — fast, and enough to inspect the code. We throw away stdout on success (the progress bar is noise) but on failure we re-run with output so you can see why the clone failed, then bail with exit 2.
else
PLUGIN_DIR="$HOME/.config/omarchy/plugins/$ARG"
if [[ ! -d "$PLUGIN_DIR" ]]; then
fail "plugin not found: $PLUGIN_DIR"
echo " pass a git URL to vet before installing, or an installed plugin id" >&2
exit 1
fi
info "Vetting installed plugin: $PLUGIN_DIR"
REMOTE_URL="$(git -C "$PLUGIN_DIR" remote get-url origin 2>/dev/null || true)"
[[ -n "$REMOTE_URL" ]] && info "Remote: $REMOTE_URL"
fi
The “vet already-installed” path. Point at the installed directory, and pull the origin URL out of its git config so we can still run Scorecard against the upstream. The || true means “don’t care if there’s no remote” — we just won’t get a Scorecard that run.
Extracting the GitHub owner/repo
owner_repo=""
if [[ -n "$REMOTE_URL" ]]; then
u="${REMOTE_URL%.git}"
u="${u#*github.com}"
u="${u#:}"
u="${u#/}"
owner_repo="$u"
fi
Scorecard wants owner/repo. This strips a trailing .git, everything up to and including github.com, then a leading : (for [email protected]:owner/repo) or /. It’s not a fully general URL parser — it assumes GitHub — but that matches Scorecard’s own GitHub-only scope, and it deliberately leaves owner_repo empty for non-GitHub remotes so the next section can say “no GitHub remote detected” instead of feeding Scorecard garbage.
Check 1: git metadata
section "git metadata"
if git -C "$PLUGIN_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
last_date="$(git -C "$PLUGIN_DIR" log -1 --format=%cd --date=short 2>/dev/null)"
commits="$(git -C "$PLUGIN_DIR" rev-list --count HEAD 2>/dev/null || echo "?")"
authors="$(git -C "$PLUGIN_DIR" shortlog -sne HEAD 2>/dev/null | wc -l | tr -d ' ')"
Guard on “is this even a git work tree?” first. Then pull the last commit date (%cd = committer date, --date=short = YYYY-MM-DD), the total commit count, and the number of distinct authors via shortlog -sne (summary, no body, include email). Each is wrapped in 2>/dev/null so a shallow clone with limited history degrades to ? rather than crashing.
if [[ -n "$last_date" ]]; then
now_s=$(date +%s); then_s=$(date -d "$last_date" +%s 2>/dev/null || echo "$now_s")
age_days=$(( (now_s - then_s) / 86400 ))
else
age_days="?"
fi
Convert the last-commit date to a “days ago” number using epoch seconds. 86400 is seconds per day.
info "last commit: ${last_date:-?} (${age_days} days ago), ${commits} commits, ${authors} author(s)"
[[ -n "$TMP_CLONE" ]] && info "(shallow clone — history limited; vet the GitHub repo for full metadata)"
[[ "${authors:-0}" =~ ^[0-9]+$ ]] && (( authors <= 1 )) && warn "single-author repo"
[[ "${age_days:-999}" =~ ^[0-9]+$ ]] && (( age_days > 365 )) && warn "no commits in over a year (possibly unmaintained)"
Print the summary, then two heuristics. The =~ ^[0-9]+$ guard ensures we only compare when the value is actually numeric (a ? would otherwise error under (( ))). Single-author and stale repos get [WARN]s — not failures, but things to factor into your trust decision.
Check 2: OpenSSF Scorecard (three-tier fallback)
section "OpenSSF Scorecard"
if [[ -z "$owner_repo" ]]; then
info "no GitHub remote detected (Scorecard covers GitHub repos only)"
Tier 0: no GitHub remote, no Scorecard. Be honest and move on.
elif command -v scorecard >/dev/null 2>&1; then
if [[ -z "${GITHUB_AUTH_TOKEN:-}" ]] && command -v gh >/dev/null 2>&1; then
GITHUB_AUTH_TOKEN="$(gh auth token 2>/dev/null || true)"; export GITHUB_AUTH_TOKEN
fi
[[ -z "${GITHUB_AUTH_TOKEN:-}" ]] && warn "GITHUB_AUTH_TOKEN unset; scorecard may hit rate limits"
Tier 1: the scorecard CLI is installed. Scorecard needs a GitHub token to avoid aggressive rate limits; if GITHUB_AUTH_TOKEN isn’t exported, try to borrow one from gh auth token. If we still don’t have one, warn (don’t fail) — it’ll work for a few runs then rate-limit.
scorecard --repo="github.com/$owner_repo" \
--checks=Branch-Protection,Dangerous-Workflow,Binary-Artifacts,Maintained,Code-Review,Pinned-Dependencies,Signed-Releases,Token-Permissions,Vulnerabilities \
--format=table 2>&1 | sed 's/^/ /' || warn "scorecard run failed"
Run the curated subset of checks I listed above. sed 's/^/ /' indents the output under the section header. The || warn catches a non-zero exit (network error, bad repo) without aborting the script.
else
resp="$(curl -fsS --max-time 15 "https://api.scorecard.dev/v2/projects/github.com/$owner_repo" 2>/dev/null || true)"
if [[ -n "$resp" ]]; then
info "pre-computed Scorecard (api.scorecard.dev):"
printf '%s\n' "$resp" | jq -r '.checks[]? | " \(.name): \(.score)/10"' 2>/dev/null | head -40 \
|| printf '%s\n' "$resp" | head -40 | sed 's/^/ /'
Tier 2: no CLI, so try the public REST API, which serves pre-computed weekly scores for popular repos. Parse with jq if possible, else dump raw. head -40 keeps it readable.
else
info "scorecard CLI not installed and no pre-computed score available"
info "View online: https://scorecard.dev/viewer/?uri=github.com/$owner_repo"
info "Install CLI: go install github.com/ossf/scorecard/v5/cmd/scorecard@latest"
fi
fi
Tier 3: nothing worked — give you a clickable web-viewer link and the install command. The script stays useful at every tier.
Check 3: binary artifacts
section "binary artifacts"
bins=0
while IFS= read -r -d '' f; do
[[ -x "$f" ]] || continue
if ! file "$f" 2>/dev/null | grep -qiE 'text|json|script|ascii|utf-8|xml|unicode'; then
printf ' %s\n' "${f#$PLUGIN_DIR/}"
bins=$((bins + 1))
fi
done < <(find "$PLUGIN_DIR" -type f -not -path '*/.git/*' -print0 2>/dev/null)
find -print0 + read -d '' is the safe way to iterate filenames that might contain spaces or newlines. For each file, skip it unless it’s executable (-x); then ask file(1) what it is. If file doesn’t describe it as some kind of text, it’s a binary — print it (with the plugin dir prefix stripped for readability) and count it. The */.git/* exclusion stops us flagging git’s own internals.
if (( bins == 0 )); then
pass "no executable/binary artifacts found"
else
fail "found $bins binary/executable file(s) — non-reviewable code, review before trusting"
high_risk=1
fi
Checked-in binaries are non-reviewable — you can’t read a compiled blob — so this is a hard [FAIL] that sets high_risk=1 and will drive the exit code to 2.
Check 4: dangerous primitives
section "dangerous primitives (review manually)"
matches="$(grep -rEn \
'XMLHttpRequest|Qt\.openUrlExternally|\.fetch\(|\bProcess\b|hl\.exec_cmd|exec_cmd|createQmlObject|\beval\(|\bFunction\(|\batob\b|\bbtoa\b|sh -c|bash -c' \
--include='*.qml' --include='*.js' --include='*.mjs' --include='*.json' \
"$PLUGIN_DIR" 2>/dev/null | grep -v '/.git/' | head -30)"
A single grep -rEn (recursive, extended regex, line numbers) across the QML/JS/JSON files, looking for the primitives that warrant a human eye: network (XMLHttpRequest, .fetch(), external launch (Qt.openUrlExternally), process spawning (Process, hl.exec_cmd, exec_cmd, sh -c, bash -c), dynamic code (createQmlObject, eval(, Function(), and base64 (atob/btoa, a common obfuscation tell). head -30 caps the output so a noisy plugin doesn’t flood the report.
if [[ -n "$matches" ]]; then
warn "network/exec/eval primitives found — inspect each use:"
printf '%s\n' "$matches" | sed 's/^/ /'
else
pass "no obvious network/exec/eval primitives in QML/JS"
fi
Hits are a [WARN], not a [FAIL] — a bar widget legitimately opens URLs. The value is that the lines are now in front of you, with file names and line numbers, so you can judge each one.
Check 5: manifest and omarchy plugin validate
section "manifest"
mf="$PLUGIN_DIR/manifest.json"
if [[ -f "$mf" ]]; then
if jq -r '" id: \(.id)\n name: \(.name)\n author: \(.author // "?")\n version: \(.version // "?")\n license: \(.license // "(none declared)")\n kinds: \(.kinds | join(", "))"' "$mf" 2>/dev/null; then
[[ -z "$(jq -r '.license // empty' "$mf" 2>/dev/null)" ]] && warn "no license declared"
else
warn "manifest.json is not valid JSON:"; sed 's/^/ /' "$mf"
fi
else
fail "manifest.json missing"
high_risk=1
fi
Parse the manifest with jq and print a one-line-per-field summary. The // "?" operator is jq’s “default if null”. A missing licence is a [WARN]; a missing or unparseable manifest is a [FAIL] (Omarchy can’t load it anyway).
if command -v omarchy >/dev/null 2>&1; then
if omarchy plugin validate "$PLUGIN_DIR" >/dev/null 2>&1; then
pass "omarchy plugin validate OK"
else
warn "omarchy plugin validate reported issues:"
omarchy plugin validate "$PLUGIN_DIR" 2>&1 | sed 's/^/ /'
fi
else
info "omarchy CLI not on PATH — skipped omarchy plugin validate"
fi
Layer in Omarchy’s own schema check — the same one the dev guide tells plugin authors to run. It’s a [WARN] if it fails (a plugin can still load with schema warnings in some cases) rather than a hard fail.
Check 6: qmllint
section "qmllint"
if command -v qmllint >/dev/null 2>&1; then
shellpath="${OMARCHY_PATH:-/usr/share/omarchy}/shell"
mapfile -d '' qml_files < <(find "$PLUGIN_DIR" -type f -name '*.qml' -not -path '*/.git/*' -print0 2>/dev/null)
Only run if qmllint exists. OMARCHY_PATH is Omarchy’s own env var for its install root; fall back to /usr/share/omarchy. mapfile -d '' reads NUL-separated filenames into an array safely.
if (( ${#qml_files[@]} == 0 )); then
info "no .qml files to lint"
else
out="$(qmllint -I "$shellpath" "${qml_files[@]}" 2>&1 || true)"
if [[ -z "$out" ]]; then
pass "qmllint clean (${#qml_files[@]} file(s))"
else
warn "qmllint findings:"; printf '%s\n' "$out" | sed 's/^/ /'
fi
fi
else
info "qmllint not installed — skipped"
fi
qmllint -I <importpath> lints against the Omarchy shell’s QML imports, so it catches references to things that don’t exist. Empty output = clean. This is a correctness check, not security — included because a plugin that fails to load can still destabilise the shared shell process.
Check 7: osv-scanner
section "osv-scanner (dependencies)"
if command -v osv-scanner >/dev/null 2>&1; then
tmp="$(mktemp)"
if osv-scanner scan -r "$PLUGIN_DIR" >"$tmp" 2>&1; then
pass "no known vulnerabilities found"
else
warn "osv-scanner findings (or scan error):"; sed 's/^/ /' "$tmp"
fi
rm -f "$tmp"
else
info "osv-scanner not installed — skipped"
info "Install: go install github.com/google/osv-scanner/v2/cmd/osv-scanner@latest"
fi
Capture osv-scanner’s output to a temp file so I can report it whether the command succeeds (no vulns) or fails (vulns found, or a scan error — either way you want to see it). Clean up the temp file. If the tool isn’t installed, skip with the install command.
The recommendation and exit code
section "recommendation"
if (( high_risk )); then
fail "high-risk findings above — do NOT enable until they are resolved"
exit 2
fi
info "No high-risk findings. This is NOT a guarantee of safety."
info "Omarchy plugins run unsandboxed with your user permissions (can read ~/.ssh, tokens, etc)."
info "Next steps if you choose to trust it:"
if [[ -n "$REMOTE_URL" && -n "$TMP_CLONE" ]]; then
info " install: omarchy plugin add $REMOTE_URL --enable"
fi
info " pin to reviewed commit: git -C ~/.config/omarchy/plugins/<id> checkout <sha>"
info " avoid 'omarchy plugin update' for untrusted plugins; re-review diffs on update"
exit 0
If any check set high_risk, say so loudly and exit 2 — a non-zero status you can gate on in a shell script (omarchy-plugin-vet <url> || echo "nope"). Otherwise, give the honest “not a guarantee” disclaimer and the concrete next steps: install (only if you vetted a URL, not an installed id), pin to a commit, and treat omarchy plugin update with suspicion for untrusted plugins. Exit 0.
The exit-code convention matters if you ever want to script this — for example, a wrapper that refuses to omarchy plugin add anything omarchy-plugin-vet hasn’t approved. I haven’t written that wrapper, on purpose, but the 0/2 split leaves the door open.
What it looks like in practice
Vetting an already-installed plugin, on a machine without the optional tools:
[INFO] Vetting installed plugin: /home/david/.config/omarchy/plugins/io.github.woogy7.workspaces
[INFO] Remote: https://github.com/Woogy7/omarchy-workspace-switcher.git
=== git metadata ===
[INFO] last commit: 2026-08-23 (11 days ago), 7 commits, 1 author(s)
[WARN] single-author repo
=== OpenSSF Scorecard ===
[INFO] scorecard CLI not installed and no pre-computed score available
[INFO] View online: https://scorecard.dev/viewer/?uri=github.com/Woogy7/omarchy-workspace-switcher
[INFO] Install CLI: go install github.com/ossf/scorecard/v5/cmd/scorecard@latest
=== binary artifacts ===
[PASS] no executable/binary artifacts found
=== dangerous primitives (review manually) ===
[PASS] no obvious network/exec/eval primitives in QML/JS
=== manifest ===
id: io.github.woogy7.workspaces
name: Workspace Switcher
author: Woogy7
version: 0.2.0
license: MIT
kinds: overlay, bar-widget
[PASS] omarchy plugin validate OK
=== qmllint ===
[INFO] qmllint not installed — skipped
=== osv-scanner (dependencies) ===
[INFO] osv-scanner not installed — skipped
[INFO] Install: go install github.com/google/osv-scanner/v2/cmd/osv-scanner@latest
=== recommendation ===
[INFO] No high-risk findings. This is NOT a guarantee of safety.
[INFO] Omarchy plugins run unsandboxed with your user permissions (can read ~/.ssh, tokens, etc).
[INFO] Next steps if you choose to trust it:
[INFO] pin to reviewed commit: git -C ~/.config/omarchy/plugins/<id> checkout <sha>
[INFO] avoid 'omarchy plugin update' for untrusted plugins; re-review diffs on update
The single-author warning is exactly the kind of thing you want to see before you decide — not a blocker, but a prompt to read the seven commits yourself.
Closing
omarchy-plugin-vet won’t make Omarchy plugins safe — nothing short of a sandbox will, and Omarchy has rightly decided that sandboxing bar widgets isn’t worth the loss of capability. What it does is move you from “I installed a thing I found on a website” to “I ran a report, I read the flagged lines, I pinned the commit, and I made a decision”. That’s the difference between being a user and being a target.
Install the optional tools, run it on every plugin before you enable it, pin the commits you trust, and re-run it whenever you update. The script is short on purpose — read it, change it, make it stricter if your threat model demands it. The whole point is that the trust decision is yours.