Bubblewrap terminal backend

Part of Hermes Official Plugins: built and supported by Nous Research.
The bubblewrap backend runs every terminal and execute_code command inside its own
bubblewrap (bwrap) sandbox on the machine Hermes runs on.
It is not a container: the sandbox sees the host filesystem read-only at the host paths, can write to
the working directory, and cannot read your credentials. It needs no image, no daemon and no network
round trip, so it fits a personal machine or a small server where the local backend is too open and
Docker is too heavy.
Linux only. Needs bubblewrap 0.9.0 or later, prlimit from util-linux (installed on every
mainstream distribution) and unprivileged user namespaces (or a setuid bwrap).
Install
sudo apt install bubblewrap # Debian, Ubuntu
sudo dnf install bubblewrap # Fedora
sudo pacman -S bubblewrap # Arch
hermes plugins install bubblewrap
hermes config set terminal.backend bubblewrap
hermes plugins install NousResearch/hermes-official-plugins#bubblewrap installs straight from this
repo's main. hermes setup lists the backend once the plugin is installed and asks for the profile.
hermes doctor reports whether bwrap is found, its version, and whether the sandbox probe passes. If
the probe fails, unprivileged user namespaces are disabled for your user: check your distribution's
notes on enabling them for bubblewrap (on Ubuntu 24.04 and later this is the AppArmor
kernel.apparmor_restrict_unprivileged_userns restriction).
When bwrap is missing or the probe fails, the terminal tool returns a degraded result that names the
package (or an error under terminal.degraded_mode: fail). Commands are never run outside the sandbox.
Settings
All settings live under plugins.entries.bubblewrap.settings (Desktop: Settings ▸ Plugins ▸
Bubblewrap). From the command line:
hermes config set plugins.entries.bubblewrap.settings.profile workspace # restricted | workspace | network
hermes config set plugins.entries.bubblewrap.settings.memory_mb 2048 # 0 disables
hermes config set plugins.entries.bubblewrap.settings.cpu_seconds 600 # 0 disables
hermes config set plugins.entries.bubblewrap.settings.max_procs 256 # 0 disables
hermes config set plugins.entries.bubblewrap.settings.binds '[{"src": "/data/models"}, {"src": "/srv/scratch", "readonly": false}]'
hermes config set plugins.entries.bubblewrap.settings.home_allow '[".config/nvim"]'
Settings are read each time a sandbox environment is created, per profile. terminal.cwd,
terminal.home_mode, terminal.timeout and terminal.sandbox_dir are the usual core terminal keys.
What bwrap isolates
Each command gets:
- The host root, read-only, at the same paths as on the host.
/usr/bin,/etc, your project
checkouts and your installed toolchains are all there; nothing outside the writable set can be
changed. - A private pid namespace and
/proc: the command cannot see or signal host processes. Every other
namespace is unshared too (IPC, UTS, cgroup, user, and network unless the profile shares it). - A fresh
/devand a fresh/tmpper command. Nothing written to/tmpsurvives the command; use
the working directory, or$TMPDIR(the Hermes scratch directory, which inside the sandbox is the
sandbox's own and persists between its commands). - No agent or bus sockets:
/run/user/<uid>is an empty tmpfs, so the gpg-agent, ssh-agent,
keyring and D-Bus sockets there are unreachable, andSSH_AUTH_SOCK,GPG_AGENT_INFOand
DBUS_SESSION_BUS_ADDRESSare removed from the environment (again after your shell rc files run).
The docker socket, if present, is replaced by an empty file. - Your home directory with its dot entries hidden unless they are on an allowlist, and credential
stores hidden always (below). - Resource limits per command (memory, CPU time, processes), applied by
prlimitbeforebwrap
starts, so nothing runs in between. --die-with-parentand--new-session: a sandbox dies with Hermes and cannot inject input into
the controlling terminal.
cd and exported variables carry over between commands exactly as with the local backend; the
environment is the one the local backend builds (env_passthrough applies, provider API keys stay
out, HOME follows terminal.home_mode).
Your home directory
Credentials live in dot entries of the home directory, and no list of them is complete: every tool
that stores a token picks its own name. So the sandbox hides every dot entry of your home directory
and shows only what is on an allowlist.
- Non-dot entries (
~/projects,~/Documents,~/bin) are visible, read-only unless the working
directory or a read-write bind covers them. - Dot entries (
~/.pgpass,~/.mozilla,~/.zz-some-tool) do not exist inside the sandbox unless
they are allowed. An allowed dot entry is read-only always, so a command cannot edit~/.bashrcor
~/.gitconfig, which would run code in your own shells later. ~/.config,~/.localand~/.local/sharefollow the same rule one level down: only an allowed
child is visible.- Nothing new can be made at the top of the home directory or of those three directories.
The allowlist has three sources: a shipped list (the shell startup files, .gitconfig, .cache, the
common toolchain directories such as .cargo, .rustup, .nvm and .pyenv, and the common children
of .config, .local and .local/share), every directory on PATH that lies under the home
directory, and the home_allow setting for anything else:
plugins:
entries:
bubblewrap:
settings:
home_allow:
- .zz-tool # a dot entry at the top of the home directory
- .config/nvim # or one child of .config, .local or .local/share
Credential stores stay hidden whatever the allowlist says: .ssh, .aws, .gnupg, .kube,
.docker, .netrc, .npmrc, .pypirc, .pgpass, .git-credentials, .config/gh,
.config/gcloud, the browser profiles, the desktop keyrings and the other paths that Hermes' own
file tools refuse to write. A home_allow entry that names one is ignored with a warning. Below an
allowed entry the same holds: ~/.cargo is visible, ~/.cargo/credentials.toml shows as an empty
file, and so do ~/.gem/credentials, ~/.m2/settings.xml and ~/.config/npm/npmrc.
An allowed dot entry that is a symlink stays a symlink inside the sandbox, so it resolves only when
its target is visible. A dotfiles repository in a dot directory (~/.gitconfig linked to
~/.dotfiles/git/gitconfig) and a CA bundle kept in one (SSL_CERT_FILE=~/.certs/corp.pem) need that
directory in home_allow ([".dotfiles"], [".certs"]): it then shows read-only, with the credential
stores below it still hidden. A repository in a non-dot directory (~/dotfiles) needs nothing.
The allowlist and the hidden set are fixed when the backend starts, so a command cannot widen them by
changing PATH. The listing of the home directory is read at every command, so a directory you create
on the host shows up in the next command.
The Hermes home
The Hermes home (HERMES_HOME, normally .hermes in your home directory) is hidden as well. Two
directories under it stay reachable: the backend's own state directory
(HERMES_HOME/sandboxes/bwrap-*) and the Hermes scratch directory (HERMES_HOME/cache/scratch, the
path Hermes names in its system prompt and points TMPDIR, TMP and TEMP at). On the host the
scratch directory is shared by every Hermes process and session, so the sandbox mounts its own
directory there instead: hermes-bwrap-session-<digest> in the shared directory on the host, named
after the terminal session and the Hermes process (hermes-bwrap-<id> for an environment built
without a session). Inside the sandbox the scratch path is writable whenever the working directory
is, read-only under restricted, and keeps its files between commands, also when Hermes makes the
session's sandbox again after the idle timeout. Files other sessions and Hermes processes keep in the
shared directory are not visible. "Session" is the terminal session Hermes keys environments by: the
CLI, cron jobs and subagents share their parent's, so they share one scratch directory as they share
one sandbox. Unused for a day, the directory is removed by Hermes' scratch pruning. With
terminal.home_mode: profile, HERMES_HOME/home is the subprocess HOME and stays readable and
writable; the rest of HERMES_HOME stays hidden, the files other sessions staged there (attachments,
cached documents, images, pastes) included. When HERMES_HOME points elsewhere (a profile under
.hermes/profiles/<name>), the default .hermes is hidden too, so a profile's sandbox cannot read the
default home's .env or auth.json.
Each sandbox sees only its own state directory: when terminal.sandbox_dir lies outside the hidden
set, it is an empty read-only directory inside the sandbox with that one entry, so no sandbox reads
another one's shell snapshot or execute_code files. Such a sandbox directory must not hold the
working directory, the home directory or a bind destination, or the backend refuses to start.
A hidden path that does not exist on the host is skipped. An entry that is a symlink (a dotfiles
repository linking .ssh to dotfiles/ssh) is hidden at its target, resolved when the backend
starts, and the parents of hidden paths inside a writable directory are pinned so a command cannot
rename them out from under the overlay. Writes into a hidden directory land in the sandbox's copy and
never reach the host.
One exception: a CA bundle named by SSL_CERT_FILE, SSL_CERT_DIR, HERMES_CA_BUNDLE,
REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE, NODE_EXTRA_CA_CERTS, GIT_SSL_CAINFO or PIP_CERT that sits
inside a hidden path (for example SSL_CERT_FILE pointing at the certifi bundle in a venv under the
Hermes home) is bound back read-only, so TLS keeps working under the network profile.
Paths named by settings and variables
The CA bundle variables above, the Hermes scratch directory, the profile home and the binds sources
name host paths that the backend mounts. Each one is resolved once, when the backend starts, and the
mount uses the resolved path, so a symlink changed later cannot redirect it. A path is refused when it:
- is missing, or for a CA bundle or the scratch directory is not a regular file (a directory for
SSL_CERT_DIRand the scratch directory); abindssource may be any existing path, a Unix socket,
a FIFO or a device node included; - lies in a directory a sandbox of this profile can write to (the working directory of any of its
sandboxes, a read-write bind, the profile home, the Hermes scratch directory, the sandbox directory),
where a command could swap it before the next command; abindssource bound at its own path
directly under such a directory is the exception, since it cannot be renamed while mounted; - for a CA bundle, the scratch directory or the profile home: resolves into a credential store, into a
dot entry of the home directory that is not allowed, or into the Hermes home outside the Python
environment Hermes runs from (credentials, transcripts and the other profiles live there).
A refused read-only binds entry or profile home stops the backend with an error naming it: skipping
a read-only bind would leave the path as writable as the directory around it. Any other refused path
is skipped with one warning in the log, and the sandbox runs without it.
A CA bundle you keep in the Hermes home therefore needs to move elsewhere (for example
/usr/local/share/ca-certificates), and one in a dot directory needs that directory in home_allow.
Unset and missing variables change nothing and log nothing.
File tools
Hermes' file tools run inside the sandbox too (Hermes 0.22.6 and later, which this plugin requires):
they see the same hidden paths, read-only mounts and scratch directory as terminal commands.
| Tool | Where it runs |
|---|---|
| read_file | In the sandbox: hidden paths do not exist, credential paths read empty. |
| write_file | In the sandbox: a path outside the writable set is refused with Read-only file system. |
| patch | In the sandbox, read and write-back alike, with the same refusal. |
| search_files | In the sandbox: ripgrep is resolved there (command -v rg), with the find/grep fallback when it is missing; Hermes' own ripgrep under the hidden Hermes home is never used. |
Each file operation costs a sandbox spawn. Hermes' own file-safety rules still apply on top.
What it does not isolate
- The rest of the host filesystem is readable. Anything your user can read outside the hidden set
(/etc, other dotfiles, secrets kept inside project directories) is readable by a command. Keep
secrets in the hidden paths. - No seccomp filter. System calls are not filtered; this is a filesystem and process boundary, not
a defense against kernel exploits. - No cgroup limits. The limits are per-process rlimits; a command that forks can use more memory in
total thanmemory_mb. - Network is all or nothing. The
networkprofile shares the host network with no egress
filtering; the other two have loopback only. - Other Unix sockets stay connectable (a read-only mount does not block
connect()): anything
outside/run/user/<uid>,/tmpand the docker socket, if a command knows its path. - No background processes. Each command is its own sandbox, and bwrap tears it down (pid namespace
included) when the command's shell exits, so abackground: truejob dies right after it starts.
The model is told background processes are unavailable on this backend. - No persistent
execute_codekernel. Hermes tries its remote session kernel first, which cannot
outlive the sandbox that starts it; it then falls back to running eachexecute_codecall as its
own script (inside the sandbox, under the same limits). Python state does not carry over between
calls, and Hermes logs oneremote kernel died at spawnwarning per call.
Working directory
The working directory (terminal.cwd, else the directory Hermes was started in, as for the local
backend) is the writable set: everything under it can be changed, everything else is read-only. Point
it at a project or scratch directory. Only that setting decides what is writable: a cd (the model's,
or a Desktop/TUI workspace outside it) moves where commands start, and they run read-only there, also
after Hermes recreates the environment. A terminal.cwd that does not exist runs commands read-only in
its nearest existing parent. With your home directory as the working directory, the existing non-dot
entries of it are writable and nothing else is: the dot entries are read-only or hidden, and no new
file or directory can be made at the top of the home directory. Hermes logs a warning in that case. A working directory of
/ is refused, and so is one under the Hermes home or a hidden path, since no command could run there.
When Hermes was started from such a place (a gateway launched from its checkout under .hermes), the
backend starts in your home directory instead, as the gateway does for the local backend.
If the working directory is deleted on the host (for example by the command's own rm -rf), later
commands run in the nearest existing parent directory, read-only, until it exists again.
Profiles
| Profile | Working directory | Network | Use it for |
|---------|-------------------|---------|------------|
| restricted | read-only | none (loopback only) | Inspection and read-only analysis |
| workspace | writable | none (loopback only) | Builds and edits that must not reach the network |
| network | writable | host network | Everything else (the default) |
Extra binds
binds mounts more host directories into the sandbox, read-only unless readonly: false; dest
defaults to src:
plugins:
entries:
bubblewrap:
settings:
binds:
- {src: /data/models}
- {src: /srv/scratch, dest: /srv/scratch, readonly: false}
A source under a hidden path is ignored with a warning. So is a source that contains a hidden path
(your home directory, say) when dest differs from src: the hidden paths are covered only at their
own location, so a copy of the tree elsewhere would show them. Bind such a source at its own path
instead. Because the root is read-only, a dest must already exist on the host or sit under a
writable mount.
A source may be a directory, a file, a Unix socket (a service or agent socket you choose to share), a
FIFO or a device node.
A bind whose source lies inside the working directory of a sandbox of this profile, inside another
read-write bind or inside the profile home is refused, and the backend does not start, unless it has
dest equal to src and sits directly under that directory with no symlink on the way: bwrap
resolves a bind source on the host for every command, so a source a command can rename or replace
with a symlink would let it choose what the next command mounts (see
Paths named by settings and variables). A missing source
stops the backend too when the bind is read-only, and is skipped with a warning when it is read-write.
Under terminal.home_mode: profile, HERMES_HOME/home must be a plain directory or a link to a
directory outside the hidden set and outside every directory a sandbox can write to, or the backend
refuses to start.
Resource limits
| Setting | Default | Limit |
|---------|---------|-------|
| memory_mb | 256 | Virtual memory per process (RLIMIT_AS) |
| cpu_seconds | 30 | CPU time per process (RLIMIT_CPU) |
| max_procs | 256 | Processes and threads the command may add (RLIMIT_NPROC) |
A value of 0 disables that limit. The defaults are deliberately tight and some everyday tools exceed
them: pip, node, cargo and most compilers need more than 256 MB of address space (the command
fails with MemoryError or a similar message: raise memory_mb to 1024 or 2048), and a long compile
or test run uses more than 30 seconds of CPU (killed, exit 137: raise cpu_seconds). max_procs is
applied on top of the threads your user already runs, because the kernel counts RLIMIT_NPROC per
user across the machine, so a fork bomb stops at about 256 processes without touching your desktop.
Approval
Because the sandbox writes to real host paths, Hermes' dangerous-command approval applies exactly as
for the local backend, including the hardline floor.
Compared with hermes-agent#116579
This plugin is that PR's backend (and the
#102875 work it salvaged, by
@TacoTakumi) moved onto Hermes' terminal backend plugin interface, with
no core changes:
- Settings moved from
terminal.bubblewrap_*toplugins.entries.bubblewrap.settings.*. - The sandbox notice reaches the model as a plugin system prompt section instead of a core prompt edit.
- Background jobs are not refused up front (core has no plugin flag for that yet); they die with their
sandbox, as described above. execute_codereaches the per-call path by falling back from the session kernel, instead of a core
branch that skips it.- The gateway starts in the process directory, or your home directory when that is hidden, instead of
MESSAGING_CWD.
Tested with bubblewrap 0.11.1 on Linux 7.0; the original work was tested with 0.9.0 on kernel 6.8.
README from the reviewed commit a50b0fe; it updates when the author re-pins.
NousResearch


