Lightweight Neovim plugin to block network access from plugins.
- Works on macOS and Linux (no root required).
- Blocks network access inside the Neovim process.
- Supports
block_all,blocklist, andallowlistmodes. - Optional built-in declarative policy engine (
allow/deny/prompt_once).
Sometimes you want Neovim to be fully offline (security, focus, reproducibility) or to
allow only a small set of trusted plugins to talk to the network. nvim-sandman gives
you a simple, reversible switch without touching system firewalls.
The plugin wraps common ways plugins reach network-capable paths or spawn processes
(vim.system, vim.fn.jobstart, uv.spawn, TCP/UDP connect/send, etc.). Calls are
allowed or blocked based on current mode and the plugin detected from call stack.
Policy support is integrated into the same wrapper layer, so you do not need to run a second plugin that patches the same APIs.
- Run Neovim in offline mode by default and temporarily allow specific plugins.
- Audit which plugins try to access the network.
- Prevent accidental downloads during demos or tests.
- Add rule-based controls for command execution (
curl,git,rg, ...).
- Neovim 0.10+ for the plugin itself.
- Neovim 0.12+ to install it with built-in
vim.pack.
{
'stasfilin/nvim-sandman',
config = function()
require('nvim_sandman').setup({
enabled = false,
mode = 'block_all', -- block_all | blocklist | allowlist
})
end
}vim.pack is built into Neovim 0.12+, so this method requires Neovim 0.12 or newer.
vim.pack.add({
{
src = 'https://github.com/stasfilin/nvim-sandman',
version = 'v1.2.1',
},
})
require('nvim_sandman').setup({
enabled = false,
mode = 'block_all',
})If you prefer to track the default branch instead of a tagged release:
vim.pack.add({
{ src = 'https://github.com/stasfilin/nvim-sandman' },
})
require('nvim_sandman').setup({
enabled = false,
mode = 'block_all',
})require('nvim_sandman').setup({
enabled = true,
mode = 'block_all',
allow = { 'lazy.nvim' },
ignore_notifications = { 'nvim-treesitter', 'mason.nvim' },
})Common flows:
- Block everything (default), then allow a trusted plugin:
:Sandman allow-only lazy.nvim - Temporarily enable network for 30 seconds:
:Sandman temp-net 30000 - See what tried to reach the network:
:Sandman stats
Enable this if you want declarative per-action rules in addition to Sandman core modes.
require('nvim_sandman').setup({
enabled = true,
mode = 'block_all',
allow = { 'lazy.nvim' },
policy = {
enabled = true,
mode = 'enforce', -- monitor | enforce
default = 'prompt_once', -- allow | deny | prompt_once
audit = {
enabled = true,
path = vim.fn.stdpath('state') .. '/nvim-sandman-policy-audit.jsonl',
},
rules = {
{ id = 'allow-rg', action = 'exec', exe = 'rg', decision = 'allow' },
{ id = 'deny-curl', action = 'exec', exe = 'curl', decision = 'deny' },
{ id = 'prompt-node', action = 'exec', exe = 'node', decision = 'prompt_once' },
},
},
})Policy notes:
- Decision modes:
allow,deny,prompt_once - Enforcement modes:
monitor(log only),enforce(deny blocks) - Rule matching (MVP):
action,actor,exe,args_any,target_pattern - Action classes (current):
exec,socket(best-effort)
- A call is checked by Sandman mode (
block_all/blocklist/allowlist). - If policy is enabled, the same call is also checked by policy rules.
- If either system blocks, the final result is blocked.
- Rules are evaluated top-to-bottom.
- First matching rule wins.
- If nothing matches,
policy.defaultis used.
id: optional identifier for audit readability.action: currentlyexecorsocket.decision:allow,deny,prompt_once.actor: exact plugin name match (best-effort attribution).actor_pattern: Lua pattern or/vim-regex/match for actor when exact match is too narrow.exe: executable basename (curl,git,rg, ...).args_any: match if any listed argument is present.target_pattern: pattern/regex-like check against normalized target.
Compatibility note:
- Legacy configs that used pattern-like values in
actorstill work for now. - Those rules emit a warning and should be migrated to
actor_pattern.
- On first match, user is prompted with Allow/Deny.
- Decision is cached for current Neovim session by
(actor, action, target). - Restarting Neovim clears this cache.
monitor: no blocking from policy, but logs decision and matched rule.enforce: policydenyblocks the call.- Sandman core mode still applies in both cases.
Policy audit is JSONL, one event per line. Common fields:
ts,action,target,cwdactor,actor_confidencedecision,rule_id,mode,result
Allow common dev tooling, deny risky fetch tools:
policy = {
enabled = true,
mode = 'enforce',
default = 'deny',
rules = {
{ id = 'allow-git', action = 'exec', exe = 'git', decision = 'allow' },
{ id = 'allow-rg', action = 'exec', exe = 'rg', decision = 'allow' },
{ id = 'deny-curl', action = 'exec', exe = 'curl', decision = 'deny' },
{ id = 'deny-wget', action = 'exec', exe = 'wget', decision = 'deny' },
},
}Prompt before running script runtimes:
policy = {
enabled = true,
mode = 'enforce',
default = 'allow',
rules = {
{ id = 'prompt-node', action = 'exec', exe = 'node', decision = 'prompt_once' },
{ id = 'prompt-python', action = 'exec', exe = 'python', decision = 'prompt_once' },
},
}Silent rollout first, then enforce:
policy = {
enabled = true,
mode = 'monitor',
default = 'allow',
rules = {
{ id = 'deny-curl', action = 'exec', exe = 'curl', decision = 'deny' },
},
}
-- switch mode to 'enforce' after audit review:Sandman block- block network for all plugins.:Sandman unblock- disable blocking.:Sandman block-only <p1> <p2>- block only listed plugins.:Sandman allow-only <p1> <p2>- allow network only for listed plugins.:Sandman stats- show summary stats.:Sandman stats-reset- reset stats.:Sandman env-clear- restore/clear proxy env values.:Sandman temp-net [ms]- temporarily enable network for N ms (default 60000).:Sandman policy-status- show policy status/mode.:Sandman policy-audit [N]- show last N policy audit lines.
local nb = require('nvim_sandman')
nb.block()
nb.unblock()
nb.block_only({ 'nvim-treesitter', 'lazy.nvim' })
nb.allow_only({ 'plenary.nvim' })
print(vim.inspect(nb.stats()))
nb.stats_reset()
print(nb.stats_summary())
nb.temp_net(30000)
print(vim.inspect(nb.policy_status()))
print(table.concat(nb.policy_audit_tail(20), '\n'))require('nvim_sandman').setup({
enabled = false,
mode = 'block_all', -- block_all | blocklist | allowlist
allow = { 'plenary.nvim' },
block = { 'nvim-treesitter' },
ignore_notifications = { 'nvim-treesitter' }, -- suppress blocked notifications for listed plugins
env_block = true, -- strict block_all: poison HTTP(S)/ALL proxy env vars process-wide
temp_net_ms = 60000, -- default duration for :Sandman temp-net
stats = {
enabled = true,
storage = 'memory', -- memory | file
path = vim.fn.stdpath('state') .. '/nvim-sandman-stats.json', -- used when storage='file'
},
commands = true, -- create commands
on_block = function(info)
-- info.action, info.plugin, info.message
vim.notify(info.message, vim.log.levels.WARN)
end,
detect_plugin = function()
-- custom plugin detection (return name or nil)
end,
policy = {
enabled = false,
mode = 'enforce', -- monitor | enforce
default = 'prompt_once', -- allow | deny | prompt_once
audit = {
enabled = true,
path = vim.fn.stdpath('state') .. '/nvim-sandman-policy-audit.jsonl',
},
rules = {
-- ordered top-to-bottom, first match wins
-- { id = 'deny-curl', action = 'exec', exe = 'curl', decision = 'deny' },
},
},
})ignore_notifications matching is case-insensitive and accepts both plugin and
plugin.nvim forms (for example, gitsigns or gitsigns.nvim).
For ignored plugins, both built-in notifications and on_block callback execution
are suppressed.
Stats are collected in memory by default. You can inspect them via nb.stats()
or :Sandman stats. A summary includes totals plus the top plugins by attempts.
Storage options:
stats = falseorstats = { enabled = false }: disable stats collection.stats = { storage = 'memory' }: in-memory only (default).stats = { storage = 'file', path = '...' }: persist stats to a JSON file and restore on next startup.
Example output:
nvim-sandman stats: attempts=7 blocked=5 allowed=2
plugin lazy.nvim: attempts=3 blocked=3 allowed=0
plugin nvim-treesitter: attempts=2 blocked=2 allowed=0
block_all: everything is blocked, except plugins inallow.blocklist: only plugins inblockare blocked.allowlist: everything is blocked, except plugins inallow.
env_block note:
- Proxy env vars are process-wide, so they cannot be applied per plugin.
- In strict
block_all, proxy env vars are poisoned process-wide for hard blocking. - For plugins listed in
allow, wrapped calls temporarily restore original proxy env values only for that call, then restore the global lock. - In
blocklistandallowlist, Sandman relies on call interception only.
nvim-sandman: blocked ... from unknown:
- This means actor attribution could not map stack frames to a known plugin path.
- Common reasons: manual
:luacalls, timer/callback boundaries, C frames, generic wrappers. - You can provide custom
detect_pluginto improve attribution.
Manual command testing is blocked in block_all:
:lua print(vim.fn.system('curl ...'))is usually actorunknownand will be blocked.- Use
:Sandman temp-net 10000for temporary allowance.
Policy appears not to block:
- Verify
policy.enabled = true. - Verify
policy.mode = 'enforce'(notmonitor). - Check rule ordering and
policy.defaultfallback. - Inspect audit with
:Sandman policy-audit 50.
Too many prompts with prompt_once:
- Cache key includes
target, so command variations can create new prompts. - Use more explicit
allow/denyrules for stable high-volume commands.
No. It only intercepts calls made inside the Neovim process.
Usually no. Restart Neovim or stop that process for full effect.
Yes.
- Use
blocklistwith one plugin inblock. - Or use
allowlistwith one plugin inallow.
Use :Sandman temp-net [ms].
No. Prefer one wrapper layer to avoid monkey-patch stacking conflicts.
It never blocks by policy, but logs what policy would decide.
Policy deny blocks the call. Sandman core mode still applies.
No. Cache is session-only.
Start with policy.mode = "monitor", inspect audit logs, then switch to enforce.
Attribution is best-effort and stack-based; some call paths are not attributable.
Yes. Use custom detect_plugin and map manual calls to a synthetic actor (for example manual), then add it to allow.
No. It improves runtime control inside Neovim, not OS-level isolation.
- Start with
block_all, then add trusted plugins toallow. - Use
:Sandman statsto discover which plugins are attempting network access. - If you need policy rules, enable integrated
policyinstead of stacking another wrapper plugin. - For policy rollout, use
monitorfirst, thenenforce.
- This is not a system firewall. It only blocks calls inside the Neovim process.
- If a plugin uses an external process/daemon outside Neovim, it may bypass this.
- Stats are in-memory by default; file persistence is available via
stats.storage = "file". - Blocking after a long-lived background process is already running may not stop it. Restart Neovim or stop that process to fully enforce blocking.
- Actor attribution and socket classification are heuristic.
The plugin name is detected from the call stack file path. Supported directories:
site/pack/.../start, lazy/, plugged/, bundle/.
- Source for Pages is in
site/. - Deployment workflow:
.github/workflows/pages.yml. - Main entry page:
site/index.md.
Issues and PRs are welcome. Please keep changes focused and include a short description of the behavior you expect. If your change affects behavior, add or update a test if applicable.
- Run
npm test(usesluajit tests/run.lua .). - Current suite focuses on
env_blockbehavior across modes, including strictblock_all + allowto ensure allowed plugins can pass while global proxy lock remains active.
Apache License 2.0