Disclosure
I maintain camouflage.nvim, one of the three plugins compared here. All three were measured the same way, and the method is at the end if you want to check it.
Say you're sharing your screen in a pairing session, or recording a walkthrough for the team, and you paste a new database password into values.yaml. If the mask lands one frame after the text, the password is on screen for that frame. Nobody on the call will read it. The recording keeps it, though, and anyone watching later can scrub back and pause there.
I know of three Neovim plugins made to hide values in files like .env while you share your screen: cloak.nvim, shelter.nvim and camouflage.nvim. They differ in what happens while you edit, and in which files they can read at all.
How masking works
None of them touch the file. The plugin works out where each value sits, puts an extmark on it with virt_text_pos = "overlay", and Neovim draws the mask over the real characters. Anything that reads the buffer still gets the real text: grep, the LSP, completion, yy, any AI tool you've got attached. So these plugins protect your screen and nothing else.1
For each plugin, then, the question is whether there's ever a frame where a value is drawn without its mask.
The race with the redraw
Masking a file when it opens is the easy case. Edits are harder. You type a value, paste one or put a line from a register, and Neovim redraws straight after. If the mask isn't in place by then, the value is on screen for at least a frame.
Each of the three hooks in at a different point.
cloak.nvim re-masks in its TextChanged and TextChangedI autocmds. Neovim fires those before the redraw, so typed text is covered. It redoes the whole buffer every time, and at the size of a normal .env that's fine.
shelter.nvim attaches with nvim_buf_attach and re-masks inside on_lines, which runs as part of the change itself. It only touches the changed lines, and it wraps vim.paste so a bracketed paste is masked as it lands. When an edit changes the number of lines, though, it hands the work to vim.schedule and re-masks the whole buffer, and that runs after the redraw.
camouflage.nvim's parsers read the whole file, and for JSON, YAML, TOML, XML and HCL they go through TreeSitter. Doing that inside on_lines would mean a full structural parse on every keystroke, against a tree that hasn't seen the edit yet. So it masks in two layers.
The first layer runs inside on_lines and doesn't parse anything. For each changed row it looks for where a value starts, from KEY=value, key: value, "any key": value, ENV KEY value, <element>value, or a mask already on the row, and puts a provisional mask from there to the end of the value.
vim.api.nvim_buf_attach(bufnr, false, {
on_lines = function(_, buf, _, first, _, last_new)
if state.buffers[buf] == nil then
return true -- the buffer isn't tracked any more, detach
end
guard.mask_rows(buf, first, last_new)
end,
})
The second layer is the real parse. It runs when you pause typing, works out the exact ranges from the file's structure, and swaps the provisional masks for exact ones in a single step, with no redraw in between.
On a 500-line .env the first layer costs 0.006 ms per changed row, median. It has two limits. Until you stop typing it can cover a value your policy rules would leave visible. And a new row in the middle of a multi-line value, or a .netrc line of bare tokens, has no separator for it to find, so that row stays visible until you pause and the full parse runs.
What reaches the screen
You can only learn so much from reading the hooks, so I recorded what actually gets drawn. A small harness starts nvim --embed with one plugin loaded and attaches a UI over msgpack-RPC, the protocol GUIs use. It rebuilds the screen from grid_line events and keeps a frame each time Neovim flushes. Then it uses the editor the way a person would: it opens a .env, types a new KEY=value at 60 ms a key, appends to a value that's already masked, puts a line with "ap, and pastes two lines through nvim_paste. Every frame is checked for characters of the secret in plain text.
| Scenario | cloak.nvim | shelter.nvim | camouflage.nvim |
|---|---|---|---|
| Open a file | masked | masked | masked |
Type a new KEY=value
|
masked | masked | masked |
| Append to a masked value | masked | masked | masked |
"ap a line from a register |
masked | 1 frame visible | masked |
| Bracketed paste, 2 lines | 1 frame visible | masked | masked |
A frame is about 17 ms. Nobody on a live call reads a value in that time, but on a recording someone can stop on it.
In my runs all three had the mask up on the first frame, across three starts of nvim --embed .env per plugin.2
What it costs
Timing a parser on a string misses most of the work, so I timed each plugin until its marks were written in the buffer, on the same generated .env lines. camouflage ran twice, once with its default checks and once with them off.
A full pass, masking the whole buffer from scratch, median in milliseconds:
| Lines | cloak | shelter | camouflage, checks off | camouflage, defaults |
|---|---|---|---|---|
| 10 | 0.04 | 0.02 | 0.05 | 0.10 |
| 100 | 0.24 | 0.19 | 0.45 | 0.88 |
| 500 | 1.20 | 1.02 | 2.41 | 4.60 |
| 2,000 | 4.84 | 4.22 | 10.48 | 18.46 |
Editing the first line, then re-masking through each plugin's own change path:
| Lines | cloak | shelter | camouflage, checks off | camouflage, defaults |
|---|---|---|---|---|
| 10 | 0.04 | 0.01 | 0.05 | 0.10 |
| 100 | 0.25 | 0.03 | 0.48 | 0.89 |
| 500 | 1.25 | 0.18 | 2.60 | 4.73 |
| 2,000 | 5.04 | 0.83 | 11.45 | 21.56 |
On a full pass cloak and shelter come out close. Once the ranges are known, most of the time goes on nvim_buf_set_extmark calls, and those cost the same no matter which language found the range. camouflage takes about twice as long with its checks off and about four times with them on. With checks off, the extra is the general pipeline every file goes through, a Lua parser plus policy rules and hooks. With them on, the per-value checks behind the weak secret and JWT expiry badges add about the same again, and a project that doesn't want them can turn them off.
On edits shelter is clearly fastest, because it re-parses only the line you changed. At 500 lines it's about seven times quicker than cloak. camouflage doesn't run its full parse on every keystroke: while you type it pays the 0.006 ms per row from earlier, and the edit table shows what the parse costs when you pause.
The budget for all of this is one frame, 16.7 ms at 60 Hz. Every cell up to 500 lines is well under it.3 Only a 2,000-line file with camouflage's default checks goes over.
Where the secrets live
So far it's all been .env, because that's the one format all three read. In a real project it's rarely the only place secrets live. A .NET service keeps its connection string in appsettings.json, a Helm chart has a database password in values.yaml, Terraform has terraform.tfvars, a Dockerfile has ENV API_TOKEN ..., and your home directory has ~/.netrc.
cloak can take a Lua pattern per file type, like :.+ for YAML, but a pattern sees one line at a time and has no idea which key that line belongs to. shelter's parser only does dotenv. camouflage parses env, JSON, YAML, TOML, INI and properties files, netrc, XML, .http files, Terraform and HCL, and Dockerfiles, and it keeps track of nested keys like ConnectionStrings.Default.
Out of the box it masks every value it finds. A .camouflage.yaml in the repo says what stays readable. It's data only, and nothing in it gets executed:
version: 1
policy:
rules:
- id: plain-settings
action: ignore
key: ['^Logging%.', '^AllowedHosts$']
With that file in place, an appsettings.json looks like this on screen:
{
"Logging": { "LogLevel": { "Default": "Information" } },
"ConnectionStrings": {
"Default": "***************************************************"
},
"Stripe": {
"SecretKey": "*******************************",
"WebhookSecret": "**********************"
},
"AllowedHosts": "*"
}
When you do need a value, :CamouflageYank copies it after a confirm prompt and clears the clipboard 30 seconds later. That matters, because yy on a masked line still copies the real text. :CamouflageAudit puts every value it would mask across the project in the quickfix window, with the file, key and length, and never the value itself.
| cloak.nvim | shelter.nvim | camouflage.nvim | |
|---|---|---|---|
| Files | any, one Lua pattern per line | dotenv only | env, JSON, YAML, TOML, INI/properties, netrc, XML, .http, Terraform/HCL, Dockerfile |
| Understands nested keys | no | no | yes |
| Build step | none | Rust toolchain on first setup | none |
| Pickers | Telescope | Telescope, fzf-lua, Snacks, oil | Telescope, Snacks |
| Completion | turns off nvim-cmp | nvim-cmp, blink.cmp | nvim-cmp |
| Beyond masking | reveal current line | partial mode, peek, ecolog integration | reveal and follow-cursor, confirmed yank, audit, policy rules, weak secret and JWT expiry badges, HIBP checks on request, parser and check APIs |
| Last commit | June 2024 | March 2026 | September 2026 |
Which one to pick
If all your secrets are in .env and a Rust toolchain doesn't bother you, shelter.nvim is careful about the screen and has the fastest edit path of the three. A put from a register that adds lines still shows one frame before the mask, though.4
If you want the smallest thing that works, cloak.nvim is a single Lua file with no build step, and it handles typing well.
If your secrets are spread across JSON, YAML, Terraform and the rest, camouflage.nvim reads those files. It's the one I run.
camouflage.nvim
{
"zeybek/camouflage.nvim",
event = { "BufReadPre", "BufNewFile" },
opts = {},
}
shelter.nvim
-- needs cargo on PATH for the first setup
{
"ph1losof/shelter.nvim",
lazy = false,
init = function()
vim.filetype.add({
filename = { [".env"] = "dotenv" },
pattern = { [".?env.*"] = "dotenv" },
})
end,
opts = {},
}
cloak.nvim
{
"laytan/cloak.nvim",
opts = {},
}
If camouflage misses a file shape you use, or masks something it shouldn't, open an issue and include an example of the file.
How I measured, so you can argue with it
It all ran on an Apple M2 with Neovim 0.12.5, one plugin per nvim --headless --clean process. Versions: cloak.nvim at 648aca6, shelter.nvim at 604e983 with its native library built by cargo build --release, camouflage.nvim 0.14.1.
The timing tables are 2,000 iterations per cell over three runs, and each cell is the lowest of the three medians, since the machine had other work on it.5 For the edit table each iteration replaces line 1 and calls the plugin's own change path: shelter_buffer(bufnr, true, { min_line = 0, max_line = 1 }), cloak.cloak(pattern) and apply_decorations(bufnr). Nothing is cleared outside the timed region. camouflage runs with project config and HIBP off, and "checks off" also turns off checks.expiry and checks.weak_secret.
The screen test used a 100 by 12 UI with ext_linegrid, and keys went in one at a time through nvim_input, 60 ms apart. shelter got the dotenv filetype mapping from its README. A frame counts as a leak when any character of the value shows in its own position after the =, and each plugin ran twice with the same counts both times.
Originally published at zeybek.dev.
-
They won't keep a secret out of a log or a prompt, which camouflage's README says plainly in its security model section. ↩
-
cloak.nvim#25 shows a flash on open in a real terminal, though, so I don't think that row is settled for cloak. ↩
-
At 500 lines camouflage with checks off is about a millisecond behind cloak, under a tenth of a frame. ↩
-
It keys off the
dotenvfiletype, and Neovim 0.12 detects.envasenv, so add thevim.filetype.addmapping from its README or nothing gets masked at all. ↩ -
camouflage's timings were taken at
4139aeb. The provisional layer doesn't change the full pass, and a spot check on 0.14.1 came out within noise (4.53 and 2.48 ms at 500 lines). ↩
Top comments (0)