Skip to content

How camouflage.nvim masks secrets in Neovim without leaking a frame

In Neovim, a pasted secret can reach the screen for one frame before the mask catches up, and a recording keeps that frame. I measured what cloak.nvim, shelter.nvim and camouflage.nvim draw while you type and paste, what they cost, and which files they can read.

Ahmet ZeybekSeptember 16, 202611 min read12 min listen

listen12 min

How camouflage.nvim masks secrets in Neovim without leaking a frame

0:00
11:37

How camouflage.nvim masks secrets in Neovim without leaking a frame

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.

diagram

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.

Scenariocloak.nvimshelter.nvimcamouflage.nvim
Open a filemaskedmaskedmasked
Type a new KEY=valuemaskedmaskedmasked
Append to a masked valuemaskedmaskedmasked
"ap a line from a registermasked1 frame visiblemasked
Bracketed paste, 2 lines1 frame visiblemaskedmasked

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:

Linescloaksheltercamouflage, checks offcamouflage, defaults
100.040.020.050.10
1000.240.190.450.88
5001.201.022.414.60
2,0004.844.2210.4818.46

Editing the first line, then re-masking through each plugin's own change path:

Linescloaksheltercamouflage, checks offcamouflage, defaults
100.040.010.050.10
1000.250.030.480.89
5001.250.182.604.73
2,0005.040.8311.4521.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.nvimshelter.nvimcamouflage.nvim
Filesany, one Lua pattern per linedotenv onlyenv, JSON, YAML, TOML, INI/properties, netrc, XML, .http, Terraform/HCL, Dockerfile
Understands nested keysnonoyes
Build stepnoneRust toolchain on first setupnone
PickersTelescopeTelescope, fzf-lua, Snacks, oilTelescope, Snacks
Completionturns off nvim-cmpnvim-cmp, blink.cmpnvim-cmp
Beyond maskingreveal current linepartial mode, peek, ecolog integrationreveal and follow-cursor, confirmed yank, audit, policy rules, weak secret and JWT expiry badges, HIBP checks on request, parser and check APIs
Last commitJune 2024March 2026September 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.

{
  "zeybek/camouflage.nvim",
  event = { "BufReadPre", "BufNewFile" },
  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.

Footnotes

  1. They won't keep a secret out of a log or a prompt, which camouflage's README says plainly in its security model section. ↩

  2. cloak.nvim#25 shows a flash on open in a real terminal, though, so I don't think that row is settled for cloak. ↩

  3. At 500 lines camouflage with checks off is about a millisecond behind cloak, under a tenth of a frame. ↩

  4. 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 at all. ↩

  5. 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). ↩

questions this post answers

Can a pasted secret show on screen for one frame before the mask catches it?

It can, and a recording keeps that frame. In the measured runs a bracketed paste of two lines showed one frame unmasked in cloak, and a `"ap` put from a register showed one frame in shelter. camouflage masked both. A frame is about 17 ms, too short to read on a live call, but anyone can pause a recording on it.

How does masking work without touching 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, grep, the LSP, completion, `yy` and any AI tool, still gets the real text. These plugins guard the screen, not logs or prompts.

Which files can each plugin mask?

With cloak you write a Lua pattern per file type, and it sees one line at a time without knowing which key the line belongs to. shelter's parser reads dotenv only. camouflage parses env, JSON, YAML, TOML, INI and properties, netrc, XML, `.http`, Terraform and HCL, and Dockerfiles, and it tracks nested keys like `ConnectionStrings.Default`.

What's the performance cost of masking on every keystroke?

On a 500-line `.env`, re-masking after an edit takes 1.25 ms in cloak, 0.18 ms in shelter and 2.60 ms in camouflage with its checks off, 4.73 ms with them on. camouflage runs that full parse when you pause, and while you type it pays 0.006 ms per changed row. One frame at 60 Hz is 16.7 ms, and only a 2,000-line file with camouflage's default checks goes over.

How do you use a secret while it's masked?

In camouflage, `:CamouflageYank` copies a value 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 but never the value. cloak can reveal the current line, and shelter has a partial mode and peek.