How camouflage.nvim masks secrets in Neovim without leaking a frame
You're sharing your screen in a pairing session, or recording a walkthrough for the team. You paste a new database password into values.yaml, and if the mask lands one frame after the text, the password is on screen for that frame. Nobody on the call reads it. The recording keeps it, and anyone who watches it later can scrub back and pause on that frame.
I know of three Neovim plugins built to hide values in files like .env while you share your screen: cloak.nvim, shelter.nvim and camouflage.nvim. Where they differ is what happens while you edit, and which files they can read at all.
How masking works
None of them touch the file. A plugin finds where each value sits and puts an extmark on it with virt_text_pos = "overlay", and Neovim draws the mask on top of the real characters. Everything that reads the buffer still gets the real text: grep, the LSP, completion, yy, and any AI tool you've got attached. These plugins guard your screen. They won't keep a secret out of a log or a prompt, and camouflage's README spells that out in its security model section.
That leaves one question per plugin: is there ever a frame where a value is drawn without its mask?
The race with the redraw
Masking a file as it opens is the simpler case. The harder one is an edit. You type a value, paste one, or put a line from a register, and Neovim redraws right after. If the mask isn't in place by then, the value is on screen for at least a frame.
The three plugins hook in at different points.
cloak.nvim re-masks in its TextChanged and TextChangedI autocmds. Neovim fires those before it redraws, so typed text is covered. It redoes the whole buffer each time, which is fine at the size of a normal .env.
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 lines that changed, and it wraps vim.paste so a bracketed paste gets masked as it lands. When an edit changes the number of lines, it hands the work to vim.schedule and re-masks the whole buffer, which 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, which is why it masks in two layers.
The first layer runs inside on_lines and parses nothing. For each changed row it finds where a value starts, from KEY=value, key: value, "any key": value, ENV KEY value, <element>value, or a mask that was already on the row, and it 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 once you pause typing, works out the exact ranges from the structure of the file, and swaps the provisional masks for exact ones in the same step, with no redraw in between.
The first layer costs 0.006 ms per changed row, median, on a 500-line .env. It has two limits. It can briefly cover a value that your policy rules would leave visible, until you stop typing. And a new row in the middle of a multi-line value, or a .netrc line made of bare tokens, gives it no separator to find, so that row stays visible until you pause and the full parse runs.
What reaches the screen
Reading the hooks only goes so far, so I recorded what 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 every time Neovim flushes. Then it uses the editor like 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. Each frame gets 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, and nobody on a live call will read a value in that time. On a recording someone can stop on it.
In my runs all three had the mask on the first frame, including three starts of nvim --embed .env per plugin. cloak.nvim#25 shows a flash on open in a real terminal, so I wouldn't call that row settled for cloak.
What it costs
Timing a parser on a string leaves out most of the work, so every plugin here is timed until its marks are written in the buffer, on the same generated .env lines. I ran camouflage twice, with its default checks and with them off.
Full pass, masking the whole buffer from nothing, 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 and 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 |
cloak and shelter land close together on a full pass. Once the ranges are known, most of the time goes into nvim_buf_set_extmark calls, and those cost the same whatever 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 time 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 as much again, and a project that doesn't want them can switch them off.
On edits shelter is clearly the fastest, since it re-parses only the line you changed, and at 500 lines it's about seven times quicker than cloak. camouflage doesn't run its full parse per keystroke. While you type it pays the 0.006 ms per row from above, and the edit column is what the parse costs when you pause.
The budget to hold all of this against is one frame, 16.7 ms at 60 Hz. Every cell up to 500 lines is well under it. At 500 lines camouflage with its checks off is about a millisecond behind cloak, less than a tenth of that frame. Only a 2,000-line file with camouflage's default checks goes over.
Where the secrets live
Everything so far has been about .env, because it's the one format all three read. In a real project it's rarely the only place with secrets. 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 doesn't know which key that line belongs to. shelter's parser is dotenv only. camouflage parses env, JSON, YAML, TOML, INI and properties files, netrc, XML, .http files, Terraform and HCL, and Dockerfiles, and it tracks nested keys like ConnectionStrings.Default.
By default it masks every value it finds. A .camouflage.yaml in the repo decides what stays readable, and it's data only, nothing in it gets executed:
version: 1
policy:
rules:
- id: plain-settings
action: ignore
key: ['^Logging%.', '^AllowedHosts$']With that file in place, this is what an appsettings.json looks like 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, which matters because yy on a masked line still copies the real text. :CamouflageAudit lists every value it would mask across the project in the quickfix window, with 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 you don't mind a Rust toolchain, shelter.nvim is careful about the screen and has the fastest edit path of the three, though a put from a register that adds lines still shows one frame before its mask. It keys off the dotenv filetype, and Neovim 0.12 detects .env as env, so add the vim.filetype.add mapping from its README or nothing gets masked.
If you want the smallest thing that works, cloak.nvim is one Lua file with no build step, and it covers typing well.
If your secrets are spread over JSON, YAML, Terraform and the rest, camouflage.nvim reads those files, and it's the one I run.
{
"zeybek/camouflage.nvim",
event = { "BufReadPre", "BufNewFile" },
opts = {},
}-- 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 = {},
}{
"laytan/cloak.nvim",
opts = {},
}If camouflage misses a file shape you use, or masks something it shouldn't, open an issue with an example of the file.
How I measured, so you can argue with it
Everything 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 come from 2,000 iterations per cell, three runs, and each cell is the lowest of the three medians, because the machine had other work on it. 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). 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, with keys sent 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.