name: plugin-security-scan description: Scan ghostty-mirror.nvim for security vulnerabilities and report them in order of importance. Strictly read-only — it reports findings in the conversation and never edits code, files issues, or commits. Use when the user asks for a security scan, audit, or review of the plugin, runs /plugin-security-scan, or wants a safety pass before a release.
Plugin security scan
Static security review of this repository. The output is a report, ordered most important first. What to do about each finding is the user's call.
Ground rules
- Read-only. Do not edit any file, create issues or TODOs, commit, or
push. Read-only shell commands (grep, git log,
gh issue list, ls) are fine; nothing that writes. - Report everything you find, even hardening-level nits — but ranked, so the user can stop reading when the severity drops below their bar.
- Find them all in one sweep. This skill has historically been run many times, each pass surfacing a bug the previous pass walked past. The recurrence is not bad luck — it's a method gap, addressed by "Completeness method" below. Aim to leave nothing for a second pass.
Don't re-report what's already acknowledged
Before reporting, reconcile against the baselines below. A finding the user has already seen and accepted (or fixed) is noise, not signal.
The historical security issues (#3, #8–#15 — the 2026-06-04 scan logs) were hard-deleted from the tracker on 2026-06-05: posting vulnerability detail in public issues was judged a disclosure mistake. Their durable content lives in this file — fixed findings under "Fixed", standing decisions under "Accepted by design". Do not suggest filing findings as public issues; anything that needs tracking goes through GitHub's private vulnerability reporting.
Run
gh issue list --label security --state all --json number,title,state,body. An empty list is the expected steady state. If an issue does appear: open → acknowledged and tracked; don't re-detail it, just note in one line whether it still reproduces (a tracked issue that's been quietly fixed is worth flagging). Closed → presumed fixed; spot-check the cited lines and only surface it if it regressed.Fixed (regression-check only — verify the defense still holds at every listed point; re-raise only as a regression, never as the old finding). Findings from the deleted issues, grouped by the defense that closed them:
valid_nameat every name entry — closed: unguardedpull/read_currentfeedingvim.cmd.colorschemea planted../name; the publicgenerate/generate_tmux/write_generated/write_tmux_generatedlacking self-guards;./..passing the pattern.light_variant_suffixvalidated at setup and re-checked at use (safe_suffix) — closed: the suffix bypassingvalid_nameinto paths and pointer lines.- tmux paths rejected at setup (quotes, backslashes, newlines) and
re-checked at the sink (
tmux_pointer_line) — closed:themes_dirinterpolated unescaped into the quotedsource-fileline tmux executes; backslash mangling inside the double quotes. - Colors normalized at every emission — closed: raw
g:terminal_color_*values carrying a newline into the generated file (snapshot_palettenow normalizes per slot);hex()emitting >6 digits for out-of-range numbers (now bounded to 24 bits). - Hardened reads (
read_head, health'sread_lines:O_NONBLOCK+ fstattype == "file"+ byte cap) — closed: planted FIFO //dev/zerosymlink hanging the editor; E484 vanish races on rawreadfile;health.luabypassing the hardened path. write_atomic(same-dirO_CREAT|O_EXCLtemp, fsync, rename, dir fsync) — closed: lstat-to-write TOCTOU; dangling-symlinkO_CREATresidue; torn/partial files under concurrent readers; hard-link write-through into a victim file; in-place truncate-and-write.- Force-path bang protection (
force_may_write+ the commands' bang, clobber scoped to its own target) — closed::ThemeToGhosttysilently destroying a hand-made theme; the bang's consent leaking to the chained tmux push. - CI pins and token scope — closed: floating action tags, stylua
version: latest, plenary cloned at HEAD, missingpermissions:block. All actions SHA-pinned, stylua and plenary version/SHA-pinned,permissions: contents: read. - Test sandbox — closed:
$XDG_CONFIG_HOMEescaping the$HOMEsandbox intests/minimal_init.lua; both now point into the throwaway dir.
Accepted by design (never re-raise as actionable; these are stable trust-model decisions, documented in CLAUDE.md, code comments, and the deleted issues' "No action planned" sections):
$PATH-resolvedpkill/tmux/pgrep— argv lists, same-user trust model.theme_file(and the tmux pointer) as a same-user control channel:sync_on_startup/sync_on_focus/:ThemeFromGhosttyapply an installed colorscheme written there.valid_nameconfines the name; execution is limited to code already on the runtimepath. Defaults off.clear_cache's check-then-delete race — nounlinkatin luv; blast radius confined to the themes dirs;vim.fs.dirtype == "file"excludes symlinks. Documented in the code comment.- The marker-line ownership model — a file whose first line is the generated marker is plugin-owned and may be cleared/regenerated, including a hand-edited copy that keeps the header (delete the first line to claim a generated file as hand-made). Documented.
- Multiple nvim instances racing on one
theme_file— last-writer-wins by design; that's how:ThemeFromGhosttysyncs windows. Documented in CLAUDE.md. - A symlinked themes_dir directory component redirecting writes/deletes —
write_atomicguards the leaf only; the dirs are assumed real directories (stated trust assumption). - A planted symlink/hard link at a write destination being replaced by the atomic rename (target untouched). Replacing the entry is the designed leaf defense, not a clobbering bug — last-writer-wins on the entry itself.
write_atomic's unconditional unlink on theO_EXCLretry — a non-plugin file at exactly<path>.<pid>.tmptakes a pid collision to exist, and a planted directory there (which unlink can't clear) only wedges writes to that one destination: same-user DoS, outside the threat model. Documented in the code comment.- The check-then-write window on the hand-made bang protection
(
force_may_write'sis_generatedread vs. the later rename) — same class asclear_cache's check-then-delete: luv offers no atomic marker-check-and-replace, the window needs a hostile same-user racer inside a user-initiated force, and the blast radius is the themes dirs. Documented in the code comment.
Re-raise one of these only if the code changed such that the reasoning no longer holds (e.g. a default flipped to on, or a name reaches a sink unguarded) — and then frame it as the regression, not the old finding.
Ignore list (not part of the plugin)
Untracked cruft in the working tree is out of scope — do not report it:
- The literal
~directory at the repo root (a stray from an unexpanded tilde). - Other untracked, ungitignored scratch in the root (
Session.vim,scratch.nvim).
These are local mess, not shipped code. Confirm with git ls-files /
git status --short that a path is actually tracked before treating it as part
of the plugin. (The .claude/settings.local.json-is-tracked observation is
in scope — it ships.)
Threat model
What counts as untrusted input to this plugin:
- Colorscheme names — arrive via the
ColorSchemeevent'smatchand viatheme_file(which any process, or another nvim instance, can write). They flow into filesystem paths, a Ghostty config line, a tmuxsource-filecommand, andvim.cmd.colorscheme. - Contents of shared files —
theme_file, the tmux pointer, and everything under boththemes_dirs live at predictable paths and are read back and acted on. tmux conf is code: a sourced file canrun-shell. - User config —
overridesvalues,reload_command,light_variant_suffix, and the tmux paths are trusted as intent but must not be corrupted into something that executes or writes outside the owned paths. Config is plain data that any other plugin can mutate aftersetup(). - Environment —
$PATHresolution forpkill/tmux/pgrep,$XDG_CONFIG_HOME/$HOMEexpansion. - Highlight-derived strings — colors read from
nvim_get_hlandg:terminal_color_*(any plugin can set these) that get written into generated files. - Hostile local process running as the same user — can plant files, symlinks, hard links, FIFOs, and directories at the plugin's predictable paths, and can win check-then-act races against it.
Completeness method
The lesson from every prior scan: a single bug class kept reappearing in a new sink that an earlier fix had skipped. Catch them together by working from the defense outward, not from the file top-down.
Enumerate every peer of each defended sink. When you find a guard (
valid_name,normalize_color/hex, the hardenedread_headopen,write_atomic), do not just confirm it where you found it — list every call site of that sink class and verify the guard is present at each. The historical misses were exactly the unguarded peer:pullwhile the writes were guarded; the public generators whilewrite_generatedwas guarded;light_variant_suffixwhile the name was guarded;health.lua's read whileread_headwas hardened. Build the list, then check it off.- Names →
valid_name: every public function taking a name (generate,generate_tmux,write_generated,write_tmux_generated,resolve,resolve_tmux,push,push_tmux,read_current,pull), plus anything concatenated onto a validated name (light_variant_suffixviasafe_suffix/target_name). - Colors →
hex/normalize_color: every value emitted into a generated file — Normal/Cursor/Visual, the 16 palette slots, overrides, and every tmuxset -g ...value. - File reads → the hardened open (
O_NONBLOCK+ fstattype == "file"+ byte cap):read_head,health.lua's config read, and any rawreadfile/io.open/vim.fn.readfileanywhere. - File writes →
write_atomic: everywritefile/fs_writesink.
- Names →
Walk the filesystem-hostile-object matrix at every
fs_open. Each prior scan found one more row. Check all of them, for both the read and write opens, in one pass:Object planted at the path Concern Defense to confirm Live symlink write/read redirected to target writes: O_EXCLtemp +fs_renamereplaces the link, never follows it; reads: fstattype == "file"+O_NONBLOCKDangling symlink O_CREATfollows it and creates the targetthe temp open is O_CREAT|O_EXCL(never follows); rename replaces the link entryHard link write lands in the link target rename swaps the directory entry; the target inode is never opened FIFO / special device open/readblocks or reads unboundedlyreads: O_NONBLOCK+ fstattype == "file"+ read cap; writes never open the destinationDirectory open/write misbehaves fstat type check on reads; fs_renameover a directory failsSwapped between check and act (TOCTOU) guard proves a different object than the one written no path-based pre-write checks exist; the rename is the act Concurrent reader mid-write torn / partial file observed temp + fsync + fs_rename: readers see old or new, never a mix; a failed write unlinks the temp and leaves the destination alonePlanted entry at the predictable temp name ( <path>.<pid>.tmp)O_EXCLcreate fails; write wedgesunlink-the-entry-and-retry-once in write_atomic(unlink doesn't follow links)Validate config at the sink, not only at
setup().M.configis plain data; any plugin can mutate it after setup. For every config value that reaches an interpreted-file sink (light_variant_suffix,tmux.themes_dir,tmux.theme_file,reload_command), confirm the character-class / quoting check repeats at the point of use — a setup-only check is bypassable.CI: one dependency-pin pass. Enumerate every third-party thing the workflow runs — each
uses:action, each tool-version input (e.g. styluaversion:), and everygit fetch/cloneof test deps (plenary) — and confirm each is pinned to a commit SHA or exact release, not a floating tag orlatest. Then confirmpermissions:is scoped down and the trigger ispull_request(notpull_request_target). These were fixed one at a time across scans; do them as a single checklist.
Scan dimensions, in priority order
- Command execution. Every
vim.system,vim.fn.system,os.execute,io.popen,jobstartcall. For each: argv list or shell string? Where does each argument come from? Can untrusted input reach it? - Injection into interpreted files. Every line written into
theme_file(Ghostty parses it) and the tmux pointer/themes (source-file,set -g— tmux executes these). Check quoting, newline and quote rejection, and that every write path (including theforcecommands andpull) is behind the name guard. (See Completeness method 1.) - Filesystem writes and deletes. Every
writefile,fs_write,mkdir,delete, and the paths they're built from. Path traversal via names,clear_cache's deletion scope, and the full hostile-object matrix at every open (Completeness method 2) — symlink, dangling symlink, hard link, FIFO, directory, TOCTOU, and write atomicity. - Untrusted reads feeding actions.
read_current->pull->vim.cmd.colorscheme; the override-stamp parser;is_generated. Can a crafted file make the plugin do something its author didn't? Confirm every read uses the hardened open (Completeness method 1). - Config-at-sink. Every config value reaching an interpreted-file or path
sink is re-validated at use, not only at
setup()(Completeness method 3). - TOCTOU and shared state. Multiple nvim instances racing on one
theme_file; check-then-write windows; torn reads; predictable temp paths in tests. - Destructive operations on user-owned files. Paths where the plugin
deletes or overwrites a file it doesn't own — the force commands overwriting a
hand-made theme,
clear_cache's scope. Weigh against the stated ownership invariant ("must not touch the user's hand-made theme files"). - CI workflow.
.github/workflows/*: the dependency-pin checklist (Completeness method 4),pull_request_targetmisuse, token permissions, script injection from PR-controlled values. - Test and packaging hygiene. Specs escaping the
$HOME/$XDG_CONFIG_HOMEsandbox intests/minimal_init.lua; tracked files that ship local state (.claude/settings.local.json); anything in the repo that would execute on a user's machine at install time beyondplugin/ghostty-mirror.lua.
Method: grep for the sinks (vim.system|vim.fn.system|os.execute|io.popen|writefile|fs_write|fs_open|delete\(|mkdir|vim.cmd|source-file|readfile), then trace each argument back to its source. Read valid_name, write_atomic, read_head, and every caller; the interesting bugs are the paths that skip the guard (Completeness method 1).
Report format
Order findings by severity, then by exploitability within a severity. Severity scale:
- Critical — arbitrary code execution or writes/deletes outside the plugin-owned paths, reachable from untrusted input.
- High — injection into an interpreted file (Ghostty/tmux config) or command argument, reachable from untrusted input.
- Medium — same sinks, but requiring a hostile local user, a race, or an unusual config to reach.
- Low — defense-in-depth gaps; correct today but one refactor away from a real bug.
- Info — observations and hardening suggestions.
Each finding:
### <n>. <title> [<severity>]
- Where: <file>:<line>
- Path: <untrusted source> -> <sink>
- Impact: <one sentence>
- Remediation: <described, not applied>
End the report with:
- a one-line-per-finding summary table;
- a one-line "Already tracked" note for any open security issues that still reproduce (by number, not re-detailed) — expected to be empty;
- a one-line note that the "Fixed" baseline defenses were re-verified as still holding (or flag any that regressed);
- the explicit statement that nothing was modified.
If the scan comes up clean at a given severity, say so — "no Critical or High findings" is a result, not filler.