Sandboxing
Choose a sandbox before starting work. The default is off, so an ordinary session can use your account's filesystem and command access. This page explains enforced boundaries and their limits, not a promise that every allowed action is safe.
Start a confined task
Prerequisites: a configured provider, a working directory, and a usable OS sandbox backend. Replace provider/model with an available model from your configured provider. Run these shell examples from the project directory; choose only one for the task you intend:
evener --model provider/model --sandbox read-only --sandbox-net off "Review the README without changing project files"evener --model provider/model --sandbox workspace-write --sandbox-net off "Fix the failing unit test and run its checks"These are real agent requests and can incur provider charges. The second permits project writes. In the browser, choose the corresponding Sandbox and Sandbox network egress launch settings before Start. Check the startup report for the actual backend, mode, and network policy. If an explicit sandbox cannot be enforced, fix the backend or choose another host rather than silently rerunning with sandbox off.
Network off applies to tools, not the model-provider connection. A test that downloads dependencies may fail in this mode; prepare dependencies first or deliberately choose a new network-enabled session. The policy is fixed for the session and cannot be relaxed by a tool call.
Use Configuration to save launch defaults, Daily work to review changes, and Security for privacy and storage limits.
How confinement works
--sandbox <mode> confines a session's file access, spawned processes, and (optionally) network egress to a policy chosen once at session start. It is off by default: an unsandboxed session keeps ordinary user access. A sandboxed session's boundary is enforced by two cooperating layers — an in-process, race-safe layer for the file tools (read_file, write_file, edit_file, apply_patch, glob, grep, list_dir) and a kernel layer (bubblewrap on Linux) for every spawned process (shell jobs, rg, stdio MCP servers, hook commands) and its descendants. The kernel layer is bubblewrap on Linux and Seatbelt (sandbox-exec) on macOS — see macOS notes.
The policy is fixed for the life of the session. Nothing the model does mid-session can relax it — the mode, the network decision, and the denylist are set from the command line (or a resumed session's persisted config), never from a tool call.
Sandboxing is opt-in on purpose. The default stays off so existing workflows are unchanged; turn it on when you want a session's blast radius contained.
With sandbox off, built-in file writes, edits, patches, and the shell's cwd may target paths outside the session working directory. Relative paths still resolve against that working directory, and an omitted cwd defaults to it. Operating-system permissions and user instructions still apply. Explicit sandbox policies retain their boundaries, and allocated session scratch keeps its separate access contract.
Implicit delegate safety floor
There is one deliberate delegate safety floor: a delegate whose structured tool scope contains no workspace-mutation tool (write_file, edit_file, apply_patch, or manage_worktree) receives a private read-only sandbox even when its parent session is off. This covers roles such as exploration and review whose shell is intended for inspection; the kernel wrapper confines that shell and every descendant, while the delegate's own session scratch remains writable. A parent already running restricted keeps that narrower read scope and applies the same write block without widening reads. A role with an explicit mutation tool follows the normal delegate floor.
On a host where no backend can enforce that box — an ordinary unprivileged Linux container without usable bubblewrap — the derived scope degrades rather than refusing the spawn. The delegate runs with no kernel wrapper; its file tools keep the identical write boundary (every write_file/edit_file outside its own scratch dir is denied) and mask the same credential denylist, and its shell is unconfined. That keeps the capability instead of deleting it: coupling the scope to an OS sandbox made every explorer delegate fail outright on such a host, and the model usually abandoned delegation rather than retrying.
It is not a pure superset of the advisory scope this floor replaced. Routing the file tools through the enforcement layer also means they refuse to traverse a symlinked directory, which an unsandboxed delegate could — the same refusal every enforced mode makes, now paid by a delegate that previously had no sandbox at all. A workspace whose ROOT is reached through a symlink is unaffected: reads anchor at the worktree, which tolerates a symlinked ancestor. Everything here is disclosed in both directions — the delegate's own environment section says which half is enforced, which half is on its honour, and what its file tools will not traverse, and the spawn result the parent reads carries the same warning.
An explicit delegate sandbox argument does not bypass the structured read-only floor, and does not get the degrade either — a caller that states the contract is refused rather than silently given a weaker box. read-only is accepted when it is also compatible with the parent's boundary, while off, workspace-write, and ordinary restricted are rejected because they permit persistent workspace writes. A net-only request is applied to the mandatory write-blocked policy and may only preserve or tighten the parent's network boundary. The same structured scope and sandbox policy are revalidated before a stable delegate is restored, and a write-blocked delegate cannot relax that inherited floor for its own descendants.
Threat model
The sandbox defends against the sandboxed session itself: a model, or anything it runs, that misbehaves or runs amok. Only sandboxed sessions are treated as even potentially hostile. Other local processes running as you are trusted: they could read ~/.ssh directly anyway, so the sandbox does not try to stop them, and it does not treat a change they make to your files mid-session (a directory renamed, a symlink retargeted, a mount moved) as an attack.
That bounds what the checks below defend. A sandboxed session cannot rearrange your home or Evener's config directory, because they are read-only or masked for it, so the re-checks at each open and spawn exist to keep the grants correct (a carve-out that moved stops being granted; one restored is granted again), not to win a race against another local process. A finding that needs an outside process to rearrange those directories at the right moment is out of scope.
Independently confined memory
The five memory tools use separate confined roots under the owning host's state root, not a workspace policy with its permissions widened. Their personal/project scope and relative paths cannot select arbitrary host roots or another project. Shared confinement rejects escapes and symlink indirection even when the session's workspace sandbox is off.
A root session's read-only role can write its authorized memory without workspace writes. A delegate only reads and searches memory, whatever its role; see memory. Memory permissions do not grant shell or network access. Fresh and restored delegates retain their parent's effective memory tool/scope ceilings. Memory tool names do not count as workspace-mutation tools for the delegate safety floor described above.
--disable-memory removes the native capability, automatic guidance and index I/O for that session, persistently through resume and compaction. It is not a general filesystem ban: an unrestricted shell or file tool keeps whatever access it already had. Stored files and earlier conversation remain. See memory for what memory is and what it is not.
Flags
| Flag | Values | Default | Meaning |
|---|---|---|---|
--sandbox | off, read-only, workspace-write, restricted | off | The enforcement mode. |
--sandbox-net | on, off | on | Network egress for the tool plane. Only applies with a non-off mode. |
Both flags are available on evener and evener serve. --sandbox-net is meaningful only when a non-off --sandbox mode is set; with --sandbox off it is ignored.
When a session starts sandboxed, Evener prints one line to stderr stating exactly what is enforced on this host — the backend, the mode, the network decision, that credential/secret paths are masked, and how the language caches are served — for example:
sandbox: bwrap enforcing restricted (network on, secrets masked, cache private)
sandbox: bwrap enforcing workspace-write (network on, secrets masked, cache shared-read/private-write)This line is read from the resolved policy, so it never overstates. The cache field is plain words for how the language caches (Go, npm, cargo) are served: shared-read/private-write reads the real host caches but keeps writes in a private overlay; private redirects the caches into a fully private per-session directory (the fallback when the host's bubblewrap lacks overlay support); none when the mode never writes caches (read-only). A degraded resolution (e.g. overlay unavailable → private) is reflected honestly in the line.
What the session is told
The model gets the same facts in its system prompt's <environment> block, as a short capability preamble: the sandbox mode and network decision, a summary of the writable roots, the masked-path count, the scratch directory behind $EVENER_SCRATCH_DIR (and $TMPDIR too, except for an unsandboxed session whose file tools are unconfined, which receives a world-usable temp container as $TMPDIR instead), the cache strategy, the resolved GOCACHE / GOMODCACHE, the toolchain residuals this doc records for the session's mode, and two probes (the exit status of git config --list, and which of go, node, rg are on PATH). An unsandboxed session gets the same block minus the sandbox-derived lines. It exists so a session reads its box instead of discovering it by failing at it.
The same rule governs it: every line is read from the resolved policy, quoted from a ruling recorded here, or measured at session start — through the session's own execution environment, so what it measures is what a real command meets. A probe that cannot run, errors, or times out renders unprobed, never a guess. Paths and counts only: no environment variable values that could be credentials. A probe is reported as the command it ran and that command's exit status, never as a broader claim: git config --list exiting 0 says the config chain is readable, not that every git operation succeeds.
The two probes are bounded independently and run concurrently. The git probe keeps its 10s bound even though a restricted git call now costs ~164ms (see restricted below): the bound is a deadline, not a wait, so it is free on that path, and it is still needed on a host where the developer toolchain cannot be named and git falls back to the slow xcrun shim. Independence means a slow git degrades only the git line.
Modes
All sandboxed modes share a common floor: a per-session writable temp directory (exported as TMPDIR), the secrets + pseudo-filesystem denylist (masked in both layers), git config/hook protection, the environment floor, and no inherited Evener file descriptors or sockets beyond stdio.
Linux bubblewrap also creates a fresh PID namespace with its own /proc and a minimal /dev. macOS uses Seatbelt policies through sandbox-exec; it does not create Linux PID or mount namespaces.
On Linux, bubblewrap gives spawned processes a private /tmp, so "anywhere" in the table below means anywhere except the rest of the host /tmp. The private /tmp exists because bubblewrap cannot stop a process connecting to a Unix socket it can see, and the host /tmp holds tmux and ssh-agent sockets that would let a sandboxed command act outside the box. Inside it the shell sees the cwd, its grants, its own scratch (writable), and, in the read-anywhere modes, the root session's whole scratch tree read-only. That tree holds the parent's and sibling delegates' scratch, which the file tools of those modes already read, so a delegate handed a file from its parent's scratch can open it from the shell too. The tree is also every sandboxed session's TMPDIR, so a socket a sibling's daemon puts there (an ssh-agent started with its default socket path, say) is connectable from the shell. That reaches only what that sibling's own sandbox already allows; an unsandboxed root's TMPDIR is not in the tree.
| Mode | File-tool reads | File-tool writes | Spawned-process reads | Spawned-process writes |
|---|---|---|---|---|
off (default) | anywhere | anywhere | anywhere | anywhere |
read-only | anywhere minus the denylist | denied (temp only) | anywhere minus the denylist | temp only |
workspace-write | anywhere minus the denylist | worktree + temp + contained caches | anywhere minus the denylist | worktree + temp + caches + git metadata (not config/hooks) |
restricted | worktree + Evener content (read-only) | worktree + temp | worktree + system read roots + developer toolchain + global git config files + hook/MCP paths + Evener content + temp | worktree + temp + git metadata (not config/hooks) |
offapplies no sandbox confinement.read-onlycannot commit or write project files; only the session temp is writable, for scratch. Use it for review, exploration, and analysis.workspace-writegrants writes to the worktree, the session temp, and contained caches. This is the natural mode for coding work that must not touch anything outside the project.restrictedis the tightest mode: the model's file tools can only browse and write inside the worktree, plus read Evener's own content (installed plugins and your skills directory, below). Spawned processes additionally get read-only access to the system roots a process needs to run —/usr,/bin,/sbin,/lib,/lib64,/etc,/opt,/nix/store, and on macOS the developer-toolchain directories below — but not/proc. The distinction is deliberate: the file tools expose what the model may browse; the kernel layer grants what a process needs to execute.On macOS the developer-toolchain directories are part of that set, read-only (ruled 2026-08-06). They are the active developer directory reported by
xcode-select -p— widened to its enclosing application bundle, typically/Applications/Xcode.app— plus/Library/Developer/CommandLineTools. Without them restricted mode could not rungitat all:/usr/bin/gitis anxcrunshim that execs the real binary out of one of those directories, and neither sits under the system roots above. A directory that is absent simply contributes nothing; a session never fails to start over one. The grant reaches the spawned layer only — the model's file tools cannot browse the toolchain — it adds nothing to any write surface, and the denylist still wins over it.The probed directories are treated as untrusted input, because
xcode-select -phonours$DEVELOPER_DIR: each candidate passes through the same shared-tree guard the hook/MCP grant uses ("Never a shared, multi-tenant tree"), so a directory at or above your home, the session's worktree, or a temp root — or fewer than two components deep — is refused. That is why a$DEVELOPER_DIRpointing at/Userscannot gutrestrictedmode: the denylist alone could not stop it, since it drops roots at or beneath a masked path and such a root sits above them.The toolchain grant alone was necessary but not sufficient. On a host with a global git config,
gitstill died underrestrictedwithfatal: unable to access '<home>/.gitconfig': Operation not permitted, because that config sits under$HOMEand the mode grants no home read.The user's global git config files are readable in
restricted, read-only (ruled 2026-08-07). They are the two filesgit-config(1)names under FILES:$XDG_CONFIG_HOME/git/config(with$HOME/.configstanding in for an unsetXDG_CONFIG_HOME) and~/.gitconfig— git reads both when both exist, so both are granted. With them,git --versionand a fullgit commitsucceed underrestrictedagainst the configuration the developer really has.The grant is deliberately narrow, and what it does not change matters as much as what it does:
- It is file-exact, and it adds no other read under the home directory. The two config FILES are granted, never
$HOMEand never the~/.configor~/.config/gitdirectories. A file that does not exist contributes nothing and never fails session start; a path that names a directory is refused outright. File-exactness is enforced where the candidates are probed — a candidate is admitted only if it exists and is not a directory — and not by the shared-tree guard, which admits directories by design because the toolchain and hook/MCP grants need it to. (This grant is not the only one that may name a path under your home directory: the hook and MCP infrastructure grant can too, deliberately — a plugin directory under~/.claudeis the ordinary case. "Nothing else in$HOMEis readable" would therefore be false; what is true is that this grant adds nothing else.) - It is read-only. Config and hook write protection is untouched: the global config cannot be written under
restricted,git config --globalandgit config --localare still denied, and.git/configand.git/hooksare still write-denied. The anti-hook-planting argument below rests on config being unwritable, never on it being unreadable. - The denylist still wins. A
credential.helperline in the global config becomes readable; the secret store it points at does not.~/.git-credentialsstays masked, alongside~/.ssh,~/.aws,~/.gnupgand the rest. - The candidates pass through the same shared-tree guard as the toolchain directories, so a configured path at or above your home, the worktree, or a temp root is refused rather than granted.
- It reaches the spawned layer only. The model's file tools stay confined to the worktree and cannot browse the config.
Because the config becomes readable, settings in it now take effect inside the sandbox —
rerere.enabled=true, for instance, makesgit commitcreate<common>/rr-cache. That is the intended behaviour: the mode runs the developer's git, not a blanked-out one.Global Git excludes and attributes files need their own readable location.
restrictedgrants access to global Git configuration files, not automatically to files named bycore.excludesFileorcore.attributesFile. Those files must already lie under a readable root, such as the worktree or a system read root. If they do not, macOS/Seatbelt reports an access warning on commands that read them; Linux/bubblewrap hides the ungranted path, so Git silently omits the rules.The project's
.gitignore,.gitattributes, and.git/info/excludeare unaffected. Move needed rules into the repository, or use--sandbox offonly if you accept removing sandbox confinement. This is a known limitation, not a configuration mistake; see design discussion.macOS Git uses the readable developer toolchain directly. The environment floor inserts the active developer directory's
usr/binbefore the first system directory onPATH(/usr/bin,/bin,/usr/sbin, or/sbin), or appends it if none is present. This bypasses thexcrunshim without overriding a developer's preferred Git earlier onPATH.This adds no read or write grant. Evener inserts the directory only after it passes the shared-tree guard, is not masked, and is readable under the mode's existing spawned-process grants. It does not grant access to the per-user
xcruncache directory.- It is file-exact, and it adds no other read under the home directory. The two config FILES are granted, never
The fail-closed floor
An explicitly requested sandbox mode is either fully enforced or refused. There is no override flag that silently weakens that request. The implicit delegate safety floor is different: when no OS backend is usable but file-tool confinement is available, it can run with confined file tools and an unconfined shell. Both parent and delegate receive a warning. The table below describes requested modes, not that fallback.
| Host capability | Modes that run | Otherwise |
|---|---|---|
| Linux with a working bubblewrap (unprivileged user namespaces usable) | all modes, --sandbox-net on or off | — |
macOS with /usr/bin/sandbox-exec (present on every stock install) | all modes, --sandbox-net on or off | — |
Linux without a usable bubblewrap, Windows, macOS without sandbox-exec, any other OS | off only | any non-off mode refuses to start |
On Linux, "bubblewrap usable" means more than the binary being present: Evener runs a real unprivileged-user-namespace probe at startup, so a host that ships bwrap but blocks unprivileged user namespaces (for example Ubuntu 24.04 with apparmor_restrict_unprivileged_userns=1) reports not capable and refuses a sandboxed mode rather than half-enforcing it.
When the host cannot satisfy a requested mode, the refusal is a legible start-time error naming the mode and the reason — the session never silently runs unconfined. This holds on resume too: a session persisted with a sandbox mode re-resolves its policy against the current host at resume time, so a host that can no longer enforce the mode fails the resume closed instead of resuming unconfined. The persisted mode is authoritative on resume (immutable across restart); the flag governs only fresh sessions.
The denylist: secrets and pseudo-filesystems
Every sandboxed mode masks a default set of paths in both layers, so neither a file tool nor a spawned process can read them:
- Credential directories and files (resolved against
$HOME):~/.ssh,~/.aws,~/.config/gcloud,~/.netrc,~/.config/evener,~/.gnupg,~/.docker/config.json,~/.kube,~/.git-credentials. When$XDG_CONFIG_HOMEmoves Evener's config root,$XDG_CONFIG_HOME/eveneris masked as well, since hub configuration and credentials live wherever that root is; removing~/.config/evenerfrom the denylist removes both, and removing the relocated root removes that one. A masked path reached through a symlink is masked at its real location too, so neither spelling reads it. - Pseudo-filesystems and runtime sockets:
/proc,/sys,/dev/fd,/dev/mem,/run/user, and the well-known privileged daemon control sockets (/run/docker.sock,/var/run/docker.sock,/run/podman/podman.sock,/run/containerd/containerd.sock,/run/dbus/system_bus_socket).
The default state root is not on this denylist. In read-only and workspace-write, file tools can read outside the worktree except where masked. That can include OAuth records in ${XDG_STATE_HOME:-$HOME/.local/state}/evener/auth/, the hub's auth-token, and secrets in run/. These modes do not guarantee that Evener's own state secrets are hidden from the session. File mode 0600 does not block processes running as the file's owner. --sandbox-net off does not block file-tool reads or prevent their contents entering model context. restricted excludes these paths only when they are outside its granted read roots. Relocated state roots must be checked against the effective policy too.
Masking /proc is load-bearing: without it a read_file("/proc/<evener-pid>/environ") would leak Evener's own environment, including the provider API key. Masking the daemon sockets turns a connect() into ECONNREFUSED, so a session cannot drive a container daemon straight to host root even with --sandbox-net off (a read-only bind of / does not block a Unix-socket connect(), and --unshare-net does not affect AF_UNIX).
One exception to "the mask wins", for the file tools: Evener's own content. Installed plugins (the plugin store's cache and bundled directories under ~/.config/evener/plugins) and your skills directory (~/.config/evener/skills) sit inside the masked ~/.config/evener by default, but they hold only content the session itself loads, such as the skills whose base_directory it hands the model, and the hook scripts of installed plugins it runs. So in every mode (restricted included) the file tools read those directories, read-only, carved out of the mask, and so do spawned processes. The rest of ~/.config/evener stays masked: hub configuration, credentials, and the plugin store's metadata (known_marketplaces.json, the registries and the marketplace clones), which records marketplace URLs that may carry a token. Updated installers prepare managed plugins in private staging, remove Git metadata, and audit content before publishing it. Legacy metadata moves to a private archive while plugin content keeps its paths.
At policy resolution, the probe samples the managed store's completed audit. When it records clean content, both backends grant cache and bundled without walking them at spawn. Publication is the authority for those roots. File tools still refuse any .git component by name, in any letter case, and Seatbelt keeps its path-free .git name rule. A managed root matches its lexical, resolved, or recorded on-disk spelling, so an APFS case or Unicode normalization does not restart the walk. All plugin-writing hubs and CLI executables must be updated before migration. An unsupported writer or external edit does not invalidate a clean marker automatically; existing policies do not re-read it per command. Same-user edits to managed content are outside this guarantee.
When the sampled state is absent or unverified, both backends keep the existing walk. The user skills directory is always walked. For walked roots, bubblewrap masks the .git entries it finds at any depth on each spawn. A carve-out it cannot inspect fully (over 50,000 entries, or with a directory it cannot list) stays masked for spawned processes. The carve-out never writes and never touches the pseudo-filesystem floor. It is cut only from Evener's own config mask, and a directory is left masked when it contains a masked path or sits under another one (so your own denylist entries, inside or above it, win, under any spelling of the same directory, letter case included), when a write root overlaps it (it would not stay read-only), or when it resolves anywhere but its own place under Evener's config mask (a symlink into, or above, a masked directory). The file tools check that resolution again when they open the directory, against the directory actually opened, so one swapped for a symlink mid-session is refused, and one not installed yet is held to where installing it would land. They also refuse a carve-out that a mask has since been pointed into or above (say ~/.ssh made a link to the skills directory, or ~/.docker to a directory inside it), on every read, and read it again once the link is gone or the directory is recreated in place; the backends re-check both at every spawn. In restricted, whose namespace starts empty, bubblewrap recreates a carve-out's symlinked spelling (a dotfile-managed ~/.config/evener) as a link to the real directory, and Seatbelt lets each link on that spelling be read as metadata (the link, never its target), so the base_directory a skill advertises works in the shell. With EVENER_NO_USER_SKILLS=1, which loads no user skills, the skills directory stays masked. The locations follow $XDG_CONFIG_HOME the way Evener does.
The shipped CLI and settings expose no denylist add/remove controls. Internal sandbox policy supports additions and credential-path removals, but those fields are not a user configuration surface. The pseudo-filesystem floor (/proc, /sys, …) remains non-removable even through that internal policy.
Git config and hooks are read-only
In the writable modes, git's object store works normally — objects, refs, the index, logs, and packed-refs are writable, so commit, add, and checkout succeed. rr-cache is writable too: it is git working state (recorded conflict resolutions), it is what git commit creates for itself when rerere.enabled=true, and it carries no directive git reads — so granting it costs nothing that config and hook protection depends on. The packed-refs grant also covers the two fixed sibling names git rewrites it through (packed-refs.lock and packed-refs.new, renamed into place), so git pack-refs and the ref packing a commit triggers work too. The two backends reach that outcome differently: Seatbelt names the granted entries, while bubblewrap — which grants by mounting, and cannot express "may create exactly this filename in a read-only directory" — binds a linked worktree's or submodule's shared common .git writable as a whole directory and re-binds the protected surfaces read-only on top of it. The two backends therefore reach the same outcome through different breadth: inside that shared common dir Linux also leaves the metadata nobody named — HEAD, ORIG_HEAD, info — writable, where macOS denies them. Everything below stays denied on both.
But every git config and hook surface is read-only:
.git/config, per-worktree config (config.worktree), submodule configs (.git/modules/*/config), and.git/hooks.- The two redirect files a git dir carries —
commondir(which common dir git reads config from) andgitdir— are write-denied for the same reason: a repointedcommondirmakes git read an attacker-controlledcore.hooksPath. - In a linked worktree, git's own writes land under
<main>/.git/worktrees/<id>/; the main repo's.git/configis read-granted (git must read common config even from a linked worktree) but never writable.
Denying a surface that does not exist yet is what stops it being planted, and on bubblewrap that denial is a mount, which materializes the name on the real filesystem and leaves it there after the session. So Evener pre-creates an absent commondir holding . before pinning it: an empty commondir is fatal to git forever after, and . is both the only content git accepts and exactly what a git dir without the file already means (it is its own common dir). No other protected surface is pre-created — none has content that could be more correct than the empty file or directory the mount leaves, and an empty config, config.worktree, gitdir, or hooks carries no directive.
Protected Git configuration and default hook directories cannot be modified by the sandboxed session, so git config --local ... fails. This does not protect every custom hook location: an existing core.hooksPath pointing into a writable root, such as a repository's .githooks, remains writable. Changes there can run as hooks in a later unsandboxed Git invocation.
Cache isolation and writable-root exceptions
Evener isolates caches with overlays or session-private redirects, subject to writable-root exceptions. Under the overlay strategy, npm and Cargo caches already covered by a persistent write grant remain writable. Their changes can affect later builds. Go caches receive stricter handling: locations that share persistent write roots are redirected into session scratch, as detailed below.
workspace-writeserves the language cache roots (~/.cachealongside a set$XDG_CACHE_HOME,~/.npmand~/.cargoalongside a set$npm_config_cacheor$CARGO_HOME, thepkgdirectory of your first GOPATH entry and of Go's$HOME/godefault, and aGOMODCACHEorGOCACHEyou set elsewhere) as a read-real, write-private overlay where the host's bubblewrap supports it: builds read warm from the real cache, but writes land in a per-session tmpfs that is discarded at session end. The Go settings are the host's, resolved once at session start (see the GOPATH note below). A root at, above or inside the worktree, sharing storage with another persistent write root (an extra writable root or a linked worktree's git metadata inside it, or it inside one, by any spelling or link), at or above your home directory or a temp root, or one that is not a directory, is never overlaid, since the discarded upper layer would swallow writes there. Such a cache is left as it is: the granted root is writable, and other writes into it fail rather than vanish. The same holds for a cache holding the session scratch. A root that does not exist yet is created empty at session start (as the go command itself would) so it can be overlaid. Each spawn judges the roots again: one removed since is recreated, one that is not a directory (a file, a link to an unmounted drive) is overlaid only once it is, one whose real location has since moved into the workspace, onto a shared tree, or to share storage with a write root is not overlaid, and one inside another is served by the outer overlay. Whether an overlay can be mounted also depends on each root's filesystem: a root the host refuses as an unprivileged overlay lower (one on an overlayfs root, or a network mount; the kernel returns EINVAL) is probed once per root and left out of the overlay rather than aborting every spawn, and the cache variables naming it — including npm's and cargo's own defaults under it — go to the session scratch. More generally, an npm or cargo cache the overlay does not serve and that no persistent write root covers is redirected into the session scratch, so a child that resolves its own default (a clean environment with noHOME) is covered too. A Go cache the overlay does not serve (set in the spawn's environment, withgo env -w, which is read again at each spawn as go itself does, or by Go's defaults under the spawn's ownHOME), one at, inside or holding a persistent write root, one whose root no longer resolves outside the persistent roots, one that cannot be a directory, or one whose root has since been removed, is redirected into the session scratch instead. So are all three Go settings when the spawn's go env file can't be judged: named by a relative path, or not the file read at session start, or hidden from the spawn (under a mask, or behind a link in bwrap's private /tmp or /dev, at any step of its link chain; the file is judged on the descriptor actually opened). A relative one is still left to the spawn, so its other settings (GOPROXY,GOPRIVATE) keep applying. When the spawn's go env file exists but cannot be read at that spawn (a FIFO, a device, an oversized file), the Go settings go to the scratch and that spawn getsGOENV=off, since reading it would block it too; once the file is readable again, spawns read it as usual.- Where overlay is unavailable (macOS/Seatbelt, or a bubblewrap without overlay support — including bubblewrap 0.9.0), the cache degrades to a session-private redirect:
GOCACHE,npm_config_cache, andCARGO_HOMEpoint into the session temp (a cold cache), never to a persistent-writable location. GOMODCACHE is redirected alongside GOCACHE, because an ambient value can name any directory.GOPATHgets the session scratch as its first entry too: Go records the checksum database's tree heads under the first entry'spkg/sumdbwhateverGOMODCACHEsays, so a cold module or toolchain download needs it writable. Inworkspace-writethe ambient GOPATH stays after it, so GOPATH-mode builds still find the packages already there: the spawn's own$GOPATH, else the host's resolved once at session start the way the go command resolves it ($GOPATH, then ago env -wsetting in the go env file$GOENVnames,<user config dir>/go/envby default, then$HOME/go). Evener reads that file rather than runninggo env, which would run whichever go is first on PATH, and any toolchain it downloads, before the sandbox exists. A spawn whose environment gives go no way to find those settings (noHOME,XDG_CONFIG_HOMEorGOENV) gets the scratch alone, as doesrestricted, which cannot read it. restrictedalways uses the session-private redirect.
Overlays preserve warm cache reads; session-private redirects start cold. The persistent writable-root exceptions above still apply to the overlay strategy.
The environment floor
On top of Evener's existing scrub of *KEY*/*SECRET*/*TOKEN*/*PASSWORD*/ *CREDENTIAL* variables, a sandboxed session raises an additional floor on every spawned process:
- Drops
SSH_AUTH_SOCK(a live ssh-agent socket is sign-anything even with~/.sshmasked),GNUPGHOME, andDOCKER_HOST. - Drops the cloud credential-agent variable families
AWS_*,GOOGLE_*,GCLOUD_*, andVAULT_*. - Drops a
KUBECONFIGthat points outside every granted root (an external cluster config the session should not reach). - Points
TMPDIRat the per-session temp and redirects the language cache variables (GOCACHE/GOMODCACHE/GOPATH/npm_config_cache/CARGO_HOME) there: all of them under the session-private cache strategy, and under the overlay the Go ones whose directory it does not serve.
Known residual: Go telemetry noise is not suppressed. Go's telemetry counter/token file lives under the user's Go config directory (outside every sandbox root by design), so a go invocation inside a sandboxed session logs one denied-write line to stderr (error acquiring upload token: ... operation not permitted) and continues; the command's exit code is unaffected. GOTELEMETRY was considered as an env-floor fix but is a report-only go env value — the Go toolchain does not read it from the process environment (go env -w GOTELEMETRY=off itself fails with "GOTELEMETRY cannot be modified"), so setting it in the floor would have been a no-op. The only ways to actually silence the line are a per-session HOME redirect or granting a new writable root for Go's config directory; both were rejected as broader than the problem warrants. Evener leaves the warning visible rather than set an ineffective environment variable.
ssh-agent and gpg-agent Unix sockets are not bind-mounted into the sandbox, and spawned commands inherit no Evener file descriptors beyond stdio — not Evener's live LLM-API connection, not a credential fd.
Network
--sandbox-net defaults to on when sandboxed: filesystem containment is the load-bearing guarantee, and egress denial is opt-in. (This deliberately differs from tools that default to deny.)
--sandbox-net off governs the tool plane:
- Model-authored spawned processes (the shell,
rg, hook commands) lose network access, including loopback. Linux uses bubblewrap's--unshare-net; macOS uses Seatbelt network restrictions. This can break tests that start or connect to a local server. MCP server subprocesses retain network access; see Hooks and MCP under a sandbox. - Evener-process tool egress —
web_fetchandweb_search— is disabled with a legible error. These are model-directed egress and stay refused under net=off regardless of the MCP network exception. - Provider-native web egress (server-side web search or fetch the provider runs for the model) is disabled, and the provider-capability registry fails closed for unknown capabilities, so
net=offcan't be silently false through a path you cannot inspect.
What --sandbox-net off does not touch: the LLM inference traffic itself. The agent still talks to its model — net=off never promised otherwise.
Denials
A denied operation returns a typed, legible error the model can reason about (the tool, the mode, and the offending path by basename), not a silent failure or a retry loop. In-process file-tool denials know the exact path; kernel denials are attributed best-effort from the command and its output-so-far. Denials are audit-logged with a redaction contract — the log records the mode, tool, a redacted path, and a truncated command, never the file contents or a full secret path.
In a non-interactive session a denial is final. In an interactive root session with a UI client attached (subscribed to the thread), a read_file, write_file, or edit_file denial for being outside the sandbox's roots can be escalated to a human-gated approval card — "Allow" / "Deny" in the web UI or the native app — that blocks the tool call until answered (evener/sandbox/escalation/requested → evener/sandbox/escalation/resolve, see docs/appwire-protocol.md). Shell/kernel denials, apply_patch, the browse tools, and a masked/git-protected/symlinked/escape denial all stay final, exactly as a non-interactive session. The model can never trigger, approve, or observe an approval: the escalation and its resolution never enter session history.
Subagents and worktrees
A sandboxed session's delegates and subagents inherit its policy floor. Worktree roots move to each delegate's own worktree, and git-metadata grants are resolved again. This separates workspace writes; it does not make sibling worktrees private.
read-only and workspace-write allow reads anywhere except masked paths, including sibling worktrees. restricted bounds reads to the delegate's worktree and its other granted roots; spawned processes have additional read grants for system files, git metadata, and configured infrastructure. See Modes for those grants and the separate file-tool and process boundaries.
Delegates cannot relax their parent's policy floor. Resumed delegates re-resolve their persisted policy against the current host. See docs/developing-evener/worktrees.md for the worktree model itself.
Hooks and MCP under a sandbox
Hooks and MCP servers are session infrastructure, so they work in every mode (ruled 2026-08-06). The session's configured hook and MCP-server paths join the spawned layer's read/exec surface in all modes — including restricted, whose spawned processes otherwise read only the worktree. Without this, a hook script installed in the plugin cache died with exit 126, "Operation not permitted", and took the session's first steering input with it.
- Hook commands (SessionStart, PreToolUse, PostToolUse, …) run under the session sandbox, same as any model-authored spawned process, with their configured paths granted — including staying network-severed under
net=off. - stdio MCP servers are spawned under the session sandbox. Trusted configuration layers can add read/exec roots for their programs and scripts; project configuration cannot. Unlike model-authored spawns, all configured MCP server subprocesses retain network access under
net=off, including servers from project configuration. Filesystem confinement, the exec floor, and file descriptor hygiene still apply. - Remote (HTTP/SSE) MCP servers are no longer refused under
net=off— they connect the same as undernet=on.
The network exception includes project-configured MCP servers. Excluding project configuration from filesystem grants does not exclude those servers from discovery or connection. Model-directed egress (web_fetch/web_search) and model-spawned processes remain network-blocked under net=off.
The grant is tightly bounded:
- Config-derived, never a glob. The roots come from the plugin directories this session actually loads (
--plugin-dirplus the enabled plugin registry) and the stdio MCP servers this session actually configures — not from a~/.claude/plugins/*-shaped pattern, which would both grant caches the session never loads and miss anything configured elsewhere. - Only inputs the model cannot write. MCP servers are read from the trusted layers only: the global config,
--mcp-configfiles, and--mcpinline specs. The per-project layer (<git root>/.evener/mcp.json) is excluded, because it sits inside the model's own write surface — a grant derived from it would let a session widen its own box (plant the file, spawn a delegate withsandbox=restricted, read whatever you named), breaking the rule that the policy is fixed for the life of the session. A project-declared MCP server still connects normally; it just cannot hand itself filesystem roots. - Adds read and exec access, not write access. Existing write permissions remain: a hook directory already under a writable root, such as the worktree in
workspace-write, stays writable. - Spawned layer only. This grant gives the file tools no browse access to a hook or MCP path.
- The denylist still wins. A hook or MCP path at or under a masked path is not granted, and a denylisted subtree inside a granted path stays masked — the pseudo-filesystem floor and the credential denylist are authoritative over this grant as over every other, except for Evener's own content (see the denylist section), which is how an installed plugin's hooks run from the plugin store.
- Never a shared, multi-tenant tree. A candidate root is refused when it is at or above the user's home directory, the session's worktree, or a temp root, or when it is fewer than two path components deep. So
/,/Users,/home,/private,/var,/Volumesand/tmpcan never be granted. Paths are symlink-resolved and stripped of their/System/Volumes/Datafirmlink alias before this check. A candidate that is the same directory as an entry directly under/is also refused, even when a symlink gives it a deeper canonical path, such as/var→/private/var. Note that the denylist alone cannot do this job: it drops roots at or beneath a masked path, and these are above them.
An MCP command or argument contributes only when it is a regular file, and it contributes the directory holding it (an interpreter resolves a script's neighbours). A directory named directly as an argument grants nothing.
A hook or server path the sandbox cannot safely serve fails that hook or server, not the session — the same outcome as before this grant existed. A failed SessionStart hook surfaces as one summarized warning line naming the hook and its exit code; its raw stderr never becomes the model's opening context.
Known residuals
Three boundary edges are deliberately documented as open rather than claimed closed:
- A pre-existing hardlink inside the worktree, or inside the read-only plugin and skills carve-out, to an out-of-tree secret is readable through it (path-based masking cannot see that two names share an inode). Nothing Evener installs creates one: plugin installs clone or copy file contents. A write through such a hardlink does not propagate to the original — the file tools write atomically via temp-plus-rename, which replaces the name with a fresh inode. This read residual is out of the threat model.
- On Linux, a protected surface pinned into existence stays on disk. bubblewrap denies a not-yet-created config/hook surface by mounting over it, and the mountpoint is real, so an absent
config.worktreeorgitdirunder a write root comes back as an empty file and an absenthooksas an empty directory, both surviving the session. Every one of those residues is inert to git, and the one that would not be —commondir— is pre-created holding.instead. The tidy alternative, leaving the surface unpinned, is exactly what the write denial exists to prevent, so the litter is accepted rather than removed. - Daemon-socket masking is a targeted list (docker, podman, containerd, dbus). An exotic or custom daemon socket under
/runthat is not on the list could still be reached; a broader/runmask is deferred because it would also hide legitimate runtime state and break DNS/daemons.
macOS notes
The macOS backend is Seatbelt (/usr/bin/sandbox-exec), driven from the same policy model as bubblewrap so all modes are expressible; caches are always served session-private (there is no overlay on macOS), and the sandbox-exec path is hard-coded as a PATH-injection defense. A host without /usr/bin/sandbox-exec refuses an explicitly requested sandbox mode — the same fail-closed floor as a Linux host without usable bubblewrap.
Seatbelt policies canonicalize every granted and denied root through symlink and firmlink resolution (/tmp → /private/tmp is pinned by test). Seatbelt judges the path a process actually names, so a read root reached through a symlink is granted under both spellings — its canonical target and its literal name — otherwise a dotfile-managed ~/.gitconfig would be denied at the link even with its target granted. The literal spelling is an alias, not extra reach: granting a symlink's own name alone does not make an ungranted (or masked) target readable through it, which is verified against the real kernel. A live parity suite runs the generated profiles under the real sandbox-exec when EVENER_SEATBELT_LIVE=1 is set.
Related
- docs/developing-evener/worktrees.md — the worktree model that backs delegate isolation.
- Environment variables — the environment variables Evener reads.
- docs/subagent-runtime-contracts.md — the runtime contracts subagents, plugins, and hooks operate under.