Sandboxing
June 30, 2026 · View on GitHub
Flemma can sandbox tool execution so that shell commands run inside a constrained filesystem. The sandbox is the harness's filesystem boundary — it enables capability-gated auto-approval (auto_approves_if_sandboxed) and backs the durable tool result store (urn:flemma:store). See Flemma as a Harness for how sandboxing fits into the broader gating surface.
The sandbox mounts the entire rootfs read-only and grants write access only to an explicit allowlist of paths. A misbehaving model cannot rm -rf /, overwrite dotfiles, or write outside your project directory.
Important
The sandbox is damage control, not prevention. It limits the blast radius when something goes wrong – the model can still do anything within its writable paths, including reading sensitive files and sending them over the network. It will not stop a bash tool from cat-ing your .env and curl-ing it to a remote server. The sandbox protects against the common accidents (a hallucinated rm, a stray write to /etc), not against a determined adversary. Always review the tools you are using and understand their potential risks, even in a sandboxed environment.
Sandboxing is enabled by default. On platforms where no backend is available (e.g., macOS), Flemma silently falls back to unsandboxed execution – no configuration changes needed.
For a quick overview of the tool system and approval workflow, see The Agent in the README.
How it works
Flemma's sandbox has two layers:
- Policy layer (
sandbox/init.lua) – defines what is allowed: which paths are writable, whether network access is permitted, whether the agent can usesudo. Tools call this layer's API; they never talk to backends directly. - Backend layer (e.g.,
sandbox/backends/bwrap.lua) – translates the policy into enforcement. The first backend is Bubblewrap (Linux). The abstraction boundary makes it straightforward to add backends for other platforms without changing the policy schema or tool integration.
When a tool executes a shell command, the sandbox wraps the argv before it is handed to Neovim's job machinery:
spawn(cmd) -- without sandbox
spawn(bwrap ... cmd) -- with sandbox
On Neovim 0.12+ the bash tool runs commands inside a hidden terminal buffer (termopen/jobstart({term=true})); on 0.11 it uses raw jobstart. Either way, the sandbox layer prepends backend arguments to the same argv. If sandboxing is disabled or no backend is available, the command runs unchanged.
Quick start
Sandboxing is on by default with sensible settings. If you are on Linux with bwrap installed, it works out of the box:
# Debian/Ubuntu
sudo apt install bubblewrap
# Fedora
sudo dnf install bubblewrap
# Arch
sudo pacman -S bubblewrap
No configuration changes are needed. Flemma auto-detects the backend and applies the default policy.
To verify, open a .chat buffer and run:
:Flemma sandbox:status
Configuration
The sandbox is a top-level config key, sibling to tools, parameters, provider, etc.
require("flemma").setup({
sandbox = {
enabled = true, -- Master switch (default: true)
backend = "auto", -- "auto" | "required" | explicit name (default: "auto")
policy = {
rw_paths = { -- Read-write paths (all others are read-only)
"urn:flemma:cwd", -- Vim global working directory (from :cd)
"urn:flemma:buffer:path", -- Directory of the current .chat file
"urn:flemma:store", -- Tool result store directory for the buffer
"/tmp", -- System temp directory
"${TMPDIR:-/tmp}", -- TMPDIR (deduped with /tmp if same)
"${XDG_CACHE_HOME:-~/.cache}", -- Package manager caches
"${XDG_DATA_HOME:-~/.local/share}", -- Package manager stores
},
network = true, -- Allow network access (default: true)
allow_privileged = false, -- Allow sudo/capabilities (default: false)
},
backends = {
bwrap = {
path = "bwrap", -- Path to bubblewrap binary
extra_args = {}, -- Additional bwrap arguments
},
},
},
})
See docs/configuration.md for the full option reference.
Backend modes
The backend field controls how Flemma selects a sandbox backend:
| Value | Behaviour |
|---|---|
"auto" | Iterate registered backends by priority, pick the first that works. Log quietly if none found. |
"required" | Same detection logic, but warn visibly on first .chat buffer entry if no backend is available. |
| explicit | Use the named backend directly. Error if it is unavailable. |
The default is "auto" – Flemma silently degrades to unsandboxed execution on platforms without a compatible backend. Use "required" if you want to be notified when sandboxing is not active:
sandbox = {
backend = "required",
}
Policy options
| Key | Default | Effect |
|---|---|---|
rw_paths | { "urn:flemma:cwd", "urn:flemma:buffer:path", "urn:flemma:store", "/tmp", ... } | Paths with read-write access. Everything else is read-only. |
network | true | Allow network access inside the sandbox. |
allow_privileged | false | Allow sudo and capabilities. When false, user namespaces drop privileges. |
Path variables
Paths in rw_paths support three expansion forms:
| Form | Example | Expansion |
|---|---|---|
urn:flemma:* | urn:flemma:cwd | Flemma-specific resolver (see below) |
$VAR | $HOME | Environment variable (nil if unset) |
${VAR:-default} | ${XDG_CACHE_HOME:-~/.cache} | Env var with fallback; ~ expanded in default |
Flemma URN variables
| URN | Expansion | Source |
|---|---|---|
urn:flemma:cwd | Vim's global working directory | vim.fn.getcwd() (set by :cd) |
urn:flemma:buffer:path | Directory containing the current .chat file | vim.fn.fnamemodify(bufname, ":p:h") |
urn:flemma:store | Tool result store directory for the buffer (created lazily) | tools.store.path_format (see docs/tools.md) |
After expansion, all paths are normalized to absolute paths with symlinks resolved. Paths subsumed by a parent path are deduplicated (e.g., /tmp and /tmp/foo collapses to just /tmp).
Paths that do not exist on disk at resolution time are then dropped from rw_paths. bwrap hard-fails on a --bind source it cannot find, so a not-yet-created grant — such as the lazily-created urn:flemma:store directory — degrades to read-only rather than breaking every sandboxed command. Both bash backends (the 0.11 jobstart path and the 0.12+ terminal path) pre-create the store directory before sandbox wrapping so writes there still succeed, but only when the command references $FLEMMA_TOOLS_STORE_PATH.
Per-buffer overrides
Override sandbox settings per-buffer using flemma.opt.sandbox in Lua frontmatter:
```lua
-- Enable sandboxing for this buffer
flemma.opt.sandbox = true
-- Disable sandboxing for this buffer
flemma.opt.sandbox = false
-- Fine-grained override
flemma.opt.sandbox = {
enabled = true,
policy = {
rw_paths = { "urn:flemma:cwd", "/data/experiments" },
network = false,
},
}
```
The boolean shorthand (flemma.opt.sandbox = true) expands to { enabled = true }.
Precedence order (highest wins)
- Runtime override (
:Flemma sandbox:enable/disable) – session-level, cleared on restart - Per-buffer frontmatter (
flemma.opt.sandbox) - Global config (
require("flemma").setup({ sandbox = { ... } }))
Auto-approval of sandboxed tools
When the sandbox is enabled and a backend is available, Flemma automatically approves tool calls for tools that declare the "auto_approves_if_sandboxed" capability. Currently only the built-in bash tool declares this capability. This means sandboxed sessions run without manual approval prompts for bash by default — the sandbox provides the safety boundary instead.
The auto-approval resolver sits at priority 25 in the approval chain, below the unified config resolver (100) and below any third-party resolvers registered at the default priority of 50. Explicit user preferences — global or per-buffer via frontmatter — always win, because the config resolver reads the merged value from all layers before the sandbox resolver gets a chance to run.
Opting out
Disable sandbox-based auto-approval globally:
require("flemma").setup({
tools = {
auto_approve_sandboxed = false, -- always require manual approval, even when sandboxed
},
})
Or exclude specific tools per-buffer in frontmatter:
```lua
flemma.opt.tools.auto_approve = { "$standard" }
flemma.opt.tools.auto_approve:remove("bash")
```
Requirements
The following conditions must all hold for sandbox auto-approval to activate:
tools.auto_approve_sandboxedis notfalse— the user hasn't globally opted out of sandbox auto-approval.- The tool declares
auto_approves_if_sandboxedin its capabilities array. Currently onlybashships this capability. - Frontmatter hasn't excluded the tool. A
removeop for the tool (e.g.,flemma.opt.tools.auto_approve:remove("bash")) opts this buffer out without changing global config. - Frontmatter hasn't set a full policy for
tools.auto_approve. Asetop signals the user is taking complete ownership of approval for this buffer — sandbox auto-approval steps aside. - Sandbox is enabled and available —
sandbox.enabled = true, no runtime disable override (:Flemma sandbox:disable), and a backend (e.g.,bwrap) validates successfully.
If any condition fails, the resolver yields (nil) and the chain continues to the next resolver.
Runtime commands
Toggle sandboxing at runtime without changing your config:
| Command | Effect |
|---|---|
:Flemma sandbox:enable | Enable sandboxing for the current session (validates backend) |
:Flemma sandbox:disable | Disable sandboxing for the current session |
:Flemma sandbox:status | Show effective state, backend, and availability |
The runtime override is session-level and applies to all buffers. Fine-grained per-buffer control stays in frontmatter.
Examples
Minimal (defaults)
The defaults give you read-only /, read-write access to your working directory and .chat file directory, plus /tmp. Network is allowed. No sudo.
require("flemma").setup({}) -- sandbox is on by default
Strict isolation
No /tmp, no network, only the project directory is writable:
require("flemma").setup({
sandbox = {
policy = {
rw_paths = { "urn:flemma:cwd" },
network = false,
},
},
})
Private /tmp
Instead of sharing the host's /tmp, mount a private tmpfs that is discarded when the sandbox exits:
require("flemma").setup({
sandbox = {
policy = {
rw_paths = { "urn:flemma:cwd", "urn:flemma:buffer:path" }, -- remove /tmp from rw_paths
},
backends = {
bwrap = {
extra_args = { "--tmpfs", "/tmp" }, -- private tmpfs inside sandbox
},
},
},
})
Disable sandboxing
require("flemma").setup({
sandbox = { enabled = false },
})
Custom backends
The sandbox uses a registry pattern identical to approval resolvers. Each backend provides two functions: available() (can this backend run?) and wrap() (translate the policy into a command wrapper).
Registering a backend
local sandbox = require("flemma.sandbox")
sandbox.register("my_backend", {
available = function(backend_config)
-- Return true if the backend can run, or false + error string
if vim.fn.executable("my-sandbox-tool") ~= 1 then
return false, "my-sandbox-tool not found"
end
return true, nil
end,
wrap = function(policy, backend_config, inner_cmd)
-- policy.rw_paths contains only absolute, deduplicated paths (no variables)
-- Return a flat string[] suitable for vim.fn.jobstart()
local args = { "my-sandbox-tool" }
for _, path in ipairs(policy.rw_paths or {}) do
vim.list_extend(args, { "--allow-write", path })
end
vim.list_extend(args, inner_cmd)
return args, nil
end,
priority = 80, -- Higher = preferred during auto-detection (default: 50)
description = "My Backend", -- Human-readable, shown in status
})
Backend contract
The wrap() function receives a fully resolved policy:
policy.rw_pathsis guaranteed to contain only absolute, deduplicated paths that exist on disk (non-existent paths are dropped during resolution, so a backend never receives a path that would fail to mount). No variables, no relative paths.policy.networkis a boolean (true= allow,false= block).policy.allow_privilegedis a boolean (true= allow sudo,false= drop privileges).- The function must return a flat
string[]suitable forvim.fn.jobstart(), ornil+ error string.
Environment variables are not the backend's concern. They flow through standard process inheritance via job_opts.env on the jobstart() call.
Auto-detection
When backend = "auto" or "required", Flemma iterates all registered backends in priority order (highest first), calls available() on each with its per-backend config, and uses the first that succeeds. Detection results are cached and invalidated when:
- A backend is registered or unregistered (registry generation counter)
- The
backendsconfig section changes (deep equality check)
This means late registration works: if another plugin registers a backend after Flemma's setup(), the next wrap_command() call will detect it automatically.
Unregistering
sandbox.unregister("my_backend") -- returns true if found
Introspection
sandbox.get("bwrap") -- returns the backend entry or nil
sandbox.get_all() -- all backends sorted by priority (deep copy)
sandbox.count() -- number of registered backends
Built-in backend: Bubblewrap
The built-in Bubblewrap backend is registered at priority 100 during setup(). It requires Linux and the bwrap binary.
What bwrap does
| Flag | Effect |
|---|---|
--ro-bind / / | Mount the entire rootfs read-only |
--bind path path | Mount each rw_paths entry read-write |
--dev /dev | Provide /dev (needed by most tools) |
--proc /proc | Provide /proc (needed by Python, Node, etc.) |
(no --tmpfs /run) | /run stays read-only — host runtime sockets (nscd, D-Bus) are preserved |
--unshare-user | Drop privileges (when allow_privileged = false) |
--unshare-pid | Isolate PID namespace |
--unshare-uts | Isolate hostname |
--unshare-ipc | Isolate IPC namespace |
--share-net | Allow network (or --unshare-net when network = false) |
--die-with-parent | Kill child when parent dies |
--new-session | Prevent keystroke injection |
Process lifecycle
When a sandboxed command times out or is cancelled:
vim.fn.jobstop()sendsSIGTERMto thebwrapprocess.--die-with-parentpropagates the signal to child processes.--unshare-pidensures the PID namespace tears down when PID 1 exits, killing all remaining processes.
This covers the normal case reliably. Background jobs spawned inside the sandbox are killed when the shell exits.
What the sandbox does and does not do
The sandbox limits the blast radius of tool execution. It is effective against the common case – a model that hallucinates a destructive command, writes to the wrong directory, or accidentally clobbers system files. It is not a security boundary against a model that is actively trying to cause harm.
What it prevents
- Writing or deleting files outside
rw_paths(e.g.,rm -rf /, overwriting~/.bashrc) - Privilege escalation via
sudo(whenallow_privileged = false) - PID, IPC, and UTS namespace leakage between the sandbox and the host
- Orphan processes surviving after the sandbox exits
What it does not prevent
- Data exfiltration. The sandbox can read all files on the filesystem (read-only). A tool can
cat ~/.ssh/id_rsaandcurlit to a remote server. Network access is allowed by default. Setnetwork = falseto block this, but many tools need network access to function. - Damage within writable paths. Anything inside
rw_pathsis fair game. If your project directory is writable (the default), the model can delete or overwrite any file in it. - Sandbox escapes. The sandbox uses user namespaces and bubblewrap – no root required, no kernel modules. A model that actively exploits kernel vulnerabilities or bwrap bugs could escape. This is not the threat model.
Known caveats
/tmp is writable by default. Many tools need a writable temp directory, so /tmp is in the default rw_paths. Since /tmp is a shared namespace on the host, a sandboxed agent could interfere with other users' temp files. Remove it from rw_paths or use a private tmpfs (see Examples) if this concerns you.
NixOS / GNU Guix
On NixOS and GNU Guix, system packages are exposed via symlinks under /run/current-system/sw/bin/. Since /run is mounted read-only (via --ro-bind / /), these symlinks are automatically available inside the sandbox without any special handling.
Signal propagation is best-effort. When a sandboxed command times out, Flemma kills the bwrap parent. Child processes are terminated via --die-with-parent and PID namespace teardown. In practice this is reliable, but a process that has deliberately escaped its session may survive briefly before kernel cleanup catches it.
Lua-level enforcement covers writes, not reads. The write and edit tools check sandbox.is_path_writable() before modifying files and refuse operations outside rw_paths. A flemma.save_to redirect is a second write-enforcement point: with the sandbox on, a destination outside rw_paths is rejected with save_to destination <path> is not writable under sandbox policy. The read tool is not sandboxed and cannot be – the sandbox policy has no read-deny list. The entire rootfs is readable by design (mirroring bwrap's --ro-bind / /). This is intentional: restricting reads would break tool functionality broadly, and the real risk from unrestricted reads is data exfiltration, which is better addressed by network = false (see above). Note that Lua-level write enforcement works independently of the backend – even on platforms without bwrap, the write and edit tools will enforce the policy when enabled = true.
API reference
The sandbox module exposes a public API for tools and plugins:
local sandbox = require("flemma.sandbox")
-- Configuration (each call materializes through the layered config; pass a bufnr
-- to resolve frontmatter overrides for that buffer, omit for global)
sandbox.resolve_config(bufnr) -- Effective config (global + per-buffer + runtime override)
sandbox.is_enabled(bufnr) -- Is sandboxing currently enabled?
sandbox.get_policy(bufnr) -- Resolved policy with path variables expanded
-- Command wrapping
sandbox.wrap_command(inner_cmd, bufnr) -- Wrap a command array; returns original if disabled
-- Path checking
sandbox.is_path_writable(path, bufnr) -- Would this path be writable under current policy?
-- Backend management
sandbox.validate_backend(bufnr) -- Is a suitable backend available?
sandbox.detect_available_backend(bufnr) -- Which backend would be auto-detected?
sandbox.register(name, definition) -- Register a backend
sandbox.register_module(module_path) -- Register a backend from a Lua module path
sandbox.unregister(name) -- Remove a backend (returns true if found)
sandbox.has(name) -- Is a backend with this name registered?
sandbox.get(name) -- Look up a backend by name
sandbox.get_all() -- All backends sorted by priority
sandbox.get_config_schema(name) -- Backend's `config_schema` (used by DISCOVER resolution)
sandbox.count() -- Number of registered backends
sandbox.clear() -- Remove every registered backend
sandbox.setup() -- Register built-in backends
-- Runtime toggle
sandbox.set_enabled(enabled) -- Override enabled state for this session
sandbox.reset_enabled() -- Clear the runtime override
sandbox.get_override() -- Current override value (nil = no override)
The bufnr argument is optional on every config-resolving call. Omit it for the global view; pass a chat buffer's bufnr to apply that buffer's flemma.opt.sandbox.* overrides.