Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 9 min read

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

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 se

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.

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.

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.

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

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.