The auto sandbox
In the auto mode, each shell command of the model runs in a kernel sandbox. A
command that stays in the sandbox runs at once, with no question. An action that
leaves the sandbox is an exit, and you answer each exit. This file tells what the
sandbox allows, what it blocks, how an exit asks, what happens when the sandbox cannot
run, and the limits that stay. docs/permissions.md has the rules
of the engine. docs/adr/0006-auto-sandbox.md records the
decision.
What auto does
- A shell command of the model runs in the sandbox. Every program that it starts runs there too: build scripts, tests, git hooks and package install scripts.
- The sandbox does not judge the command. The kernel blocks the effects that are not
allowed. A command that tries such an effect fails, usually with
Read-only file system,Permission deniedorNetwork is unreachable. - efr reads the command line before it runs. When the line names an action outside
the sandbox, such as
sudo,git pushor a write to~/.zshrc, that action is an exit. A reviewer model decides most exits from your messages; the others, such assudo, ask you (see "Exits"). - The model tries what you asked first. After a failure, it can call the shell again
with
needs, for example a path to write or the network. That call is an exit too. efr tells the model to ask for the narrowest need: a write grant for one file, the bus forsystemctl, andoutsideonly when nothing narrower fits. - Reads work as they do for you, except for secrets. So
journalctlshows in the sandbox the journal that you can read, with no grant. - Read-only system queries, such as
hostnamectl,systemctl --failedandloginctl list-sessions, run in the sandbox with no question. They reach the system or the session bus only through efr's bus filter, which lets through only read methods (see "The bus filter"). - An
a(always) answer at some exit questions saves a rule, so the same exit asks nothing again (see "Always-allow rules"). read_file,write_file,editandapply_patchrun in efrd, not in the sandbox. Inautothey follow the rules ofcautious, plus exits: a read of a sandbox mask, a write outside the project and$SCRATCH, and a write to a floor path are exits, as for a shell command.- The lines that you type in your own shell never run in the sandbox.
- A turn from the phone runs as
cautious, as before. nested_shellis not available inauto, because no shell outlives a contained call. A command forsudoorsshis an exit instead.
The envelope
The sandbox has three layers: bubblewrap (namespaces and mounts), Landlock (ABI 9 or higher, file and socket rights) and seccomp (a list of system calls that fail).
| What | In the sandbox |
|---|---|
| Writes | only in the write roots (below). Everything else is read-only. |
| Secrets | the paths of the read masks read as empty. Secret-like environment variables are removed. |
| Network | with network.mode = "proxy", only the hosts of your allow list, through efr's network proxy: the package registries and GitHub by default (see "The network proxy"). With network.mode = "none", none. Loopback works in both modes, so a test can start a server and talk to it. A server in a background job is reachable on the same port of your 127.0.0.1, and a later contained call of the same conversation reaches it on its own 127.0.0.1 and ::1 with no question (see "Ports of a job"). A line that only reads your network, such as ss -tln, runs with the host's network view instead, with no network socket (see "The host's network view"). |
| Sockets and D-Bus | no connection to a socket that the call did not make: no docker.sock, no session bus, no system bus, no ssh-agent, no desktop sockets. A read-only system query gets a filtered bus (see "The bus filter"). An approved exit opens one socket for one call (see "Desktop sockets"); a socket grant never opens a bus, a service manager's socket or a desktop socket, also through a link. |
| More rights | none. sudo fails with The "no new privileges" flag is set. No new user namespaces. |
| Other processes | no signals to your processes; no /proc/<pid>/environ, fd or root of them. ps still shows them. |
| Your terminal | the call keeps its terminal. It cannot type into your shell (TIOCSTI fails), and efr does not apply its terminal marks. efr never types an answer that may be a password into it (see "Input in the sandbox"). |
| Background processes | &, nohup and setsid stop when the call ends. Start a server and its test in one command, or start the server as a background job (see "Background jobs"): efr forwards its port to your machine. |
/tmp | private to the conversation; you do not see it. Share files through $SCRATCH. |
$SCRATCH | set to the conversation's scratch dir in every call: contained, in the exit child and in a background job. A command that sets SCRATCH itself keeps its own value. In the sandbox, the value holds for that call only: the next contained call gets the scratch dir again. |
Some tools show the sandbox's view: tty prints /dev/console, and $$ differs from
/proc/self. df and findmnt show your real mounts, and ss and ip your real
network (see "The host's network view and mounts").
Write roots
| Root | When |
|---|---|
| the turn's project | when the turn runs in a registered project |
| registered projects that the command names | per call: a path or a cd target of the line lies in the project (sandbox.write_projects = "named", the default) |
| the git dir and the common dir of a worktree or submodule project | from the record that efrd makes when you register the project |
$SCRATCH of the conversation | always |
the private /tmp and /var/tmp | always |
| private tool caches | each cache of sandbox.caches that exists, as an overlay |
sandbox.write_roots | always; floors still apply |
| a grant | exactly the path of an approved exit, for one call |
- Your home directory is never a write root. A turn in a project at
~runs ascautious, with the reason "auto cannot use your home directory as a project". Register a narrower project instead, such asefr project add ~/dotfiles. - Write roots never come from git output, from terminal marks, or from files that the sandbox can write.
- A registered project counts from the next call. No restart is necessary.
- For a worktree or a submodule,
.gitis a file. efrd reads it when you runefr project addand keeps the paths. When the file changes later, calls run without the git dir roots, and the tool result says: runefr project addagain.
Tool caches
The sandbox reads your caches (~/.cargo, ~/.rustup, ~/.cache, ~/.npm and the
others of sandbox.caches) through an overlay. Its writes go to a private layer.
Your own builds never see them. bin/, config.toml and env of ~/.cargo and
settings.toml of ~/.rustup stay read-only.
sandbox.cache_mode selects the mode: tmp (the default: writes go away after each
call), overlay (writes stay in a layer of the conversation) or readonly. tmp is
the default because the overlay mode failed the launch gate of phase 1: with all
the cache overlays, a call cost more than 10 ms at p95. The kernel writes an
overlay layer to the disk at the end of each call, and that costs time. efrd
deletes the layers of a conversation after sandbox.cache_days days without a call,
and the oldest layers first when all layers pass sandbox.cache_max_gib. Bin
directories on your PATH are never an overlay; they stay read-only. A cache inside
another cache (~/.cache/pip and ~/.cache, or ~/.cargo as a link into ~/.cache)
gets no overlay of its own: the outer overlay holds it, and its read-only paths still
apply. The order of sandbox.caches does not change this.
sandbox.keep_downloads (default false) keeps the downloads of a conversation in the
tmp mode: the download caches (~/.cargo, ~/go/pkg/mod, ~/.npm,
~/.bun/install/cache and ~/.local/share/pnpm/store) keep the private layer of the
conversation, as in the overlay mode, and the other caches stay tmp. So with the
proxy, a second cargo build in the conversation does not download the crates again.
Your own caches do not change. It is off by default because it failed the launch gate:
the kernel syncs the upper layer to the disk at the end of each call, and a call then
costs about 3 to 4 ms more (about 12 ms at p95, against the gate of 10 ms). The kept
caches share the layer lock of the conversation (below) and the clean-up of
sandbox.cache_days and sandbox.cache_max_gib. The key does not loosen the sandbox:
it keeps a private layer and opens nothing of your machine.
efr mounts the cache overlays itself, not bubblewrap, because bubblewrap cannot set the options of an overlay:
- bubblewrap makes the sandbox with every mask and every read-only path, but with no
overlay. Then the call waits. bubblewrap starts in
/, not in the start dir of the call, so no process of bubblewrap keeps a directory below an overlay. - The helper
efr-sbx layersenters the namespaces of the call. It mounts each overlay withindex=offandxino=off, and it moves the masks and read-only paths inside the cache onto the overlay, so they still apply. - The call starts. When the helper fails, the call does not run, and the model gets the reason.
With these options the kernel logs nothing for an overlay. Without them, the kernel logged two lines for each cache in each call, and an upper layer stayed busy for some milliseconds after a call, so the next call of the conversation could fail to start.
There is a trade-off. With index=on, the kernel refuses a second overlay on an upper
layer that an overlay uses already. With index=off, it does not check this, and two
overlays on one upper layer give undefined results. So in the overlay mode each call
locks the layer dir of its conversation (flock on $SBX/cache) before bubblewrap
starts. It keeps the lock until its overlays are gone. A second call of the same
conversation waits for the lock. When the lock stays held for 5 seconds, the call does
not run, and the model gets the reason. The tmp mode needs no lock: each call has
its own layers.
Deletes that reach the real cache. A delete in a cache overlay deletes only in the
private layer, so go clean -cache or "free some disk space" would free nothing. So
after a contained call, the launcher lists the files and dirs that the call deleted
in each cache layer (the whiteouts of the layer, at most 4096 paths and 256 KiB of
names). Each list is an exit (write, from the source cache_delete) that the
reviewer decides; it needs no message that names the paths, because the tool can fill
its cache again. After an allow, efrd checks each path again against its own caches,
the pins, the masks and the secrets, and deletes the paths in your real cache. One bad
path refuses the whole list. The call then shows the freed space, such as freed 1.2 GB in ~/.cache/go-build (12 paths). efr never deletes below a pin (~/.cargo/bin,
the cargo config, ~/.rustup/settings.toml), a mask or a secret. A refused list stays
private, the model reads so, and efr does not raise the same paths again in the
conversation. A dir that the call deleted and made again, a name that is not UTF-8
and the paths over the limits only count; efr does not delete them.
Read masks
A masked directory reads as empty, and a masked file reads as an empty file that a call cannot write. A mask covers the real target of a link too.
| Group | Paths |
|---|---|
| secrets | the engine's secret paths: ~/.ssh, ~/.gnupg, password stores, ~/.netrc, cloud and registry credentials, and permissions.secret_paths |
| more credential stores | ~/.aws, ~/.kube, ~/.docker, ~/.codex, password managers and other token files |
| browser and messenger profiles | Firefox, Chromium, Chrome, Brave, Signal, Slack, Discord and others |
| shell histories | ~/.zsh_history, $HISTFILE, ~/.bash_history and others |
| efr's own state | efr's data, state and runtime roots; only the conversation's $SCRATCH comes back |
| your runtime directory | $XDG_RUNTIME_DIR: every socket and the session bus |
project .env files | .env and .env.* in each write root, to depth 3 (sandbox.mask_globs); not .env.example, .env.sample or .env.template |
| your additions | sandbox.mask |
A read of a mask is a masked_read exit. Only you can approve it, because the content
then goes to the model provider. A secret of the engine never opens.
The floor
A floor path stays read-only, also inside a write root and after a grant. A mask wins over a floor at the same path.
| Floor | Paths |
|---|---|
| efr's config | the config directory and every link target in it |
| shell startup | .zshenv, .zprofile, .zshrc, .zlogin, .zlogout (also in $ZDOTDIR), .bashrc, .bash_profile, .profile, the fish config, environment.d, .pam_environment, .xprofile, .xinitrc |
| autostart and services | ~/.config/systemd, ~/.config/autostart, ~/.local/share/systemd, ~/.local/share/applications, the Hyprland, sway and i3 configs |
programs on PATH | each directory of the hidden shell's PATH that lies in a write root |
| tool configs that run code | ~/.gitconfig, ~/.config/git, ~/.cargo/config.toml, ~/.cargo/bin, ~/.local/bin, ~/.config/nvim, ~/.vimrc, ~/.config/direnv |
| dotfile link targets | the target of each link in ~, ~/.config and ~/.local/bin that lies in a write root |
| efr itself | efrd, efr, efr-sbx and its copy in $XDG_RUNTIME_DIR/efr/bin; efr's own dirs $XDG_RUNTIME_DIR/efr, ~/.local/state/efr and ~/.local/share/efr except $SCRATCH and efr's trash. A write there is a config exit: denied in every mode |
| the git surface | below |
| agent and editor configs in each write root | .mcp.json, .claude/, .codex/, .agents/, .cursor/, .opencode/, opencode.json, opencode.jsonc, .vscode/, .envrc, .direnv/, .efr/ |
| your additions | sandbox.protect |
A write to a floor path is a persistence exit, and it runs outside the sandbox (in
efrd for a file tool). For a floor FILE of the built-in groups above (shell startup,
autostart and services, programs on PATH, tool configs, dotfile link targets, agent
and editor configs), the reviewer decides when a message of yours names the file,
such as "add an alias to my zshrc" or "change the gap in my hyprland config". A
program on PATH, a unit, a desktop entry and an agent or editor config have common
names, so their bare name names nothing: write the path (~/.local/bin/git), the name
with its suffix (backup.service, tasks.json) or, for a unit, "the backup unit". A
path names a file only as whole parts of the path: bin/git does not name
src/bin/github.rs. A link target takes the group and the names of its link. A
write_file, edit or apply_patch of ~/.zshrc that links into a dotfiles dir also
writes the real file there; it takes the names of the link too, so "my zshrc" names
both, also when the dotfiles dir is no registered project. Before the write, efr copies the
file into its home store (below). Only you approve a dir, a link, the git surface and
sandbox.protect, and a floor file that no message of yours names, except the named
changes of units, cron and linger below.
Named changes of units, cron and linger. These change no file that the line names,
or they install the bytes of another file. The reviewer decides one only when a message
of yours names it, efr can copy what it changes first, so efr undo brings it back,
and the reviewer can read what it installs:
| Form | The reviewer decides when | efr copies first |
|---|---|---|
systemctl --user enable, reenable, preset, mask, add-wants, add-requires of a unit by name | a message names the unit, and its unit file was in a unit dir before the turn, outside every write root, unchanged in the turn (also a link to it) and at most 8 KiB; its text and the text of each unit of its Also= go to the reviewer | the whole ~/.config/systemd/user within the dir limits, and each link that the unit's [Install] section names |
ln -s <src> into a *.wants or *.requires dir; cp <src> into a floor dir such as ~/.config/autostart/ | a message names the target, and <src> holds the rule of a unit file; its text goes to the reviewer | the target, or the dir with what it holds |
crontab <file>, crontab - fed a literal by echo or printf, crontab -r | a message names cron or the crontab; the file holds the rule of a unit file | the text of the crontab (crontab -l) |
loginctl enable-linger, loginctl disable-linger, with no user or with your own user (by name or uid) | a message names linger | the linger setting |
Only you approve systemctl --user link, edit, revert, enable <path>,
set-environment, set-default, preset-all, a unit that was not in a unit dir before
the turn, a template unit (foo@.service), crontab -e, crontab - with any other
input, at, batch and systemd-run --user. efr reads and sets the crontab and linger
only with a crontab and a loginctl that it found when it started, in a dir that
root owns and only root can write, outside your home dir and efr's dirs. With no such
program, these forms stay with you. crontab -l only reads your crontab: it is an
outside exit, so the reviewer can let the model read the crontab before it writes all
of it again with crontab -. efr undo compares the crontab or the linger
setting with the one after the turn: when you changed it after the turn, undo leaves it
as it is. After undo removes a unit link, run systemctl --user daemon-reload; a unit
that started stays running.
Copies before a write outside the roots. In auto, before efrd runs an approved
write outside the project and $SCRATCH, it copies each file that the call can change
into its home store, ~/.local/share/efr/snapshots/home.git. This is so for a write
grant, write_file, edit and apply_patch, a floor file edit, an above_root or a
directory delete, an outside line that declares its write paths, and a delete in a
real tool cache. It happens for every approver: the reviewer or you. After the call,
efrd records the state after the call too, so efr undo can tell a later change of
yours from the call's change.
- efrd reads each file through its own file access, with
RESOLVE_NO_SYMLINKSandO_NOFOLLOW, and streams the bytes into the store. git never reads a home path. A link stays a link, and the real file behind it gets its own copy. - A write grant that opens the whole parent dir of a file (the normal case for a file
that exists) can change any file there. efrd copies the dir too, while it holds at
most
snapshot.home_max_filesfiles (2000) andsnapshot.home_max_mibMiB (256). After the call it lists the dir again, and a new file in it is "missing" before. Over the limits it copies the target only, and the turn saysnot covered: <dir> (over the copy limits). A file in the dir that no copy holds, such as a file over 16 MiB, shows only when the call changed it. - A copy holds at most 16 MiB per file. efrd never copies a mask or a secret. A masked
file that a message of yours names, such as
~/.aws/config, runs with no copy, and the turn sayschanged ~/.aws/config; efr undo cannot revert it (masked). - A copy leaves a
.gitout, unread, as a snapshot leaves a nested repository out. A change in a dir that holds a git repository, such as~/notes, runs, andefr undoreverts the files of the dir. The turn says that it does not cover~/notes/.git(no copy). A floor file in a git dir does not run. - When the call made a dir, such as the
*.wantsdir ofsystemctl --user enable,efr undoremoves it again after it removes the files in it. It removes only an empty dir (rmdir): a dir that holds a file of yours stays, and so do the dirs above it. - When efrd cannot read a file, or cannot copy a floor or persistence target, the call does not run, and the model gets the reason.
- In
manualandcautious, a file tool write outside the roots gets no copy. - The turn ends with one dim line for each changed floor file, such as
changed ~/.zshrc; efr undo reverts it, and the line of the turn counts the other files, such aschanged 1 file in ~; efr undo reverts this turn. A file behind a dotfile link keeps the path through the link and names its real file too. - The collector keeps the copies of the newest
snapshot.keep_turnsturns of each conversation, for at mostsnapshot.max_age_daysdays. efr writes nothing new to~/.local/share/efr/file-backups/(phase A), and deletes the old copies there aftersnapshot.max_age_daysdays.
Git settings
Git runs programs that a repository's config names (core.fsmonitor, hooks, filters).
So the sandbox protects the git dir of each project:
.gitis a mount point: a call cannot rename or remove it.config,config.worktreeandhooks/are read-only, also inworktrees/*and in each submodule git dir, and so are the files thatinclude.pathnames and the directory thatcore.hooksPathnames inside a write root.git add,git commit,git stash, new branches and a localgit fetchwork.git configfails.
git keeps the git dir of a submodule below .git/modules/, at its path: the
submodule vendor/foo has .git/modules/vendor/foo, and a submodule of a submodule
has .git/modules/a/modules/b. efr looks for these git dirs at each depth, down to 8
levels below modules/. A dir that holds HEAD or config is a git dir. efr does not
look in the objects, refs, logs and lfs dirs of a git dir. A relative
core.hooksPath of a submodule is relative to its work tree: its core.worktree, else
its path in .gitmodules. A symbolic link below modules/ stops the call, because git
would follow it to a git dir that efr cannot make read-only. A deeper tree keeps what
efr found read-only, and the surface guard reports submodule git dirs deeper than efr checks.
A call can still add new files to a git dir, such as commondir. After each call, the
surface guard checks the git dirs: those of the projects and their submodules, the git
dir that a .git file names, and the include files and the core.hooksPath dir that
their config names. It moves a planted setting that runs programs to
quarantine, where nothing reads it, and the turn asks you before its next call:
question: the last command changed git settings that run programs
~/p/app/.git/commondir (core.fsmonitor); moved to quarantine
keep it? y = yes, n = no
y moves the change back; n leaves it in quarantine. When nobody answers, or the
turn is interrupted, the change stays in quarantine. This question is not an approval
of a tool call: the call already ended.
Git code before an outside git line
An approved git push runs outside the sandbox with your full rights, and git runs
the code that the repository names. So before a git or gh line can run outside the
sandbox, efrd checks the git code that it runs. The check applies to a git command
that reaches a remote (push, fetch, pull, ls-remote, remote update,
submodule update, send-email) and to a gh command that runs git in the repository (pr create,
pr merge, pr checkout, repo sync, repo create --push, repo fork --clone),
when it gets an outside or upload exit.
-
efrd asks git itself, as the exit child's git finds it: the repository of the line (its
-C,--git-dirand--work-tree, through a.gitfile and acommondir), with your system, global and include files and the git variables of the hidden shell. It reads the hooks that are not samples (incore.hooksPathor thehooksdir), the config keys that run programs (the ones of the surface guard, a remote URL through a<transport>::helper orext::,protocol.allow,remote.*.vcs,submodule.*.updatewith!,gpg.ssh.defaultKeyCommand,hook.*.command, thetoCmd,ccCmd,headerCmdandsendmailCmdofsendemail, and asendemail.smtpServerthat is a program path), and the program that each one starts, alsosshfrom yourPATH. A line that can reach the submodules (push,fetch,pull,submodule update) checks each module git dir too. -
A part is writable when a sandboxed command can write its file: it lies in a registered project, in the
$SCRATCHof any conversation, below a write rule or a write root of the config, or in a write root of a running contained job or of a call that runs on past its timeout. The git floors of a project (its.gitconfig, hooks, include files and hooks path, also of worktrees and submodules) are not writable, except while a running contained job has them in its write roots. -
When a part is writable, or efrd could not check the code (git did not answer in 200 ms, git found no repository, the submodules lie too deep or behind a link), only you approve, and the question names the file:
git runs ~/p/app/vendor/x/.git/hooks/pre-push, which a sandboxed command can writeYour own hooks and settings in a registered project keep working with no extra question.
-
Before the check, efrd runs the surface guard of every running contained job, not only those of the line's dir, because a
.gitfile, acommondiror aninclude.pathcan point anywhere. A plant stops its job and goes to quarantine. efrd runs the guards before every other upload too. -
Right before the exit child starts, under the plan lock, efrd checks again. When the writable parts changed after your answer, the line does not run, and the model reads why; its next call gets a new question.
-
A git option that chooses the code that git runs (
-c,--config-env,--exec-path,--git-dir,--work-tree,--namespace,--receive-pack,--exec,--upload-pack,--template,clone -uorclone -c, and the--to-cmd,--cc-cmd,--header-cmd,--sendmail-cmdor a program path in--smtp-serverofsend-email) makes every git line that runs outside a question for you alone. -
So does a variable of a git or gh line that chooses that code: a
GIT_*name, such asGIT_CONFIG_COUNTorGIT_EXEC_PATH, anSSH_*name,HOME,XDG_CONFIG_HOMEorPATH. efrd checks the code with the hidden shell's variables, not with the line's.
Code that runs later
The model can write a Makefile, a build.rs, a package.json script or a project
.cargo/config.toml. Such code runs when you run the project yourself, outside the
sandbox. efr cannot judge it. At the end of an auto turn that changed such files,
efr names them:
efr: this turn changed files that run code later outside the sandbox:
build.rs, package.json (scripts), .envrc, sub/.git/config (core.fsmonitor)
check them before you run the project yourself
sandbox.surface_files lists the patterns. A changed .cargo/config* names the keys
that run programs, such as build.rustc-wrapper.
What returns to the hidden shell
Only two things return from a call to the conversation's hidden shell:
- the directory of a
cd, unless it is masked or in the private/tmp; - exported variables that
sandbox.promote_envlists, such asRUST_LOG. A name such asPATH,LD_PRELOADorGIT_DIRnever returns. A value with a relative path, such asnode_modules/.binorEurope/Paris, or with a path into a place that the sandbox can write, does not return either.
Functions, aliases and other exports stay in the sandbox's own state. The next
contained call of the conversation sees them, so source .venv/bin/activate works
across calls. An exit that runs outside the sandbox does not see them.
Input in the sandbox
A contained command can wait for input at its terminal, as a command outside the
sandbox can. efr never types an answer that may be a password into a contained
command, also when you follow the turn and want to answer. A prompt in the sandbox can
be a fake: the command can turn off echo or line mode itself and print any text, such
as Password:.
- A plain question, such as
[Y/n], shows the answer line as for any command, and your answer is shown as you type it. - A wait with echo off, such as a
read -s, stops the command at once. So does a visible wait whose prompt looks like a password prompt (it names a password, a passphrase, a PIN or a code), in any terminal mode. The view showssandbox: the command asked for a secret; efr does not type secrets into the sandbox, and no answer line. The model reads that the command asked for a secret inside the sandbox, and that it can ask to run the command outside withneeds.outside. - A contained command never offers
Ctrl+\, so you cannot type a manual answer into it either. - The same rules hold for a contained command that still runs in the hidden shell after its call answered (see A busy hidden shell): efrd refuses a hidden answer, an answer to a wait that looks like a password prompt and every manual answer for it.
A command outside the sandbox, such as an approved sudo or an rsync over ssh, gets
your password as before. Its question says that you may need to type your password
(see "Exits").
Exits
| Kind | Example | After a yes | Who can approve |
|---|---|---|---|
write | cp report.pdf ~/Documents/, write_file ~/notes.md, a delete in a real cache | the sandbox, with that one path writable | the reviewer, else you |
host | with the proxy: curl -LO https://example.com/x.tar.gz for a host that is not on the allow list; nslookup, dig, ping, nc; needs.hosts of localhost:5432. Without the proxy: every network need, such as npm ci | the sandbox, with that one host and port through the proxy; or with the whole network for a raw network program, localhost, a private host, another port, and every network need without the proxy | the reviewer, else you |
host_view | iw dev, ifconfig, ip link set lo mtu 1400, ss -tln; make test, and ss or ip while the network view is off | the sandbox, with the host's network | the reviewer, else you |
socket | a tool's own Unix socket | the sandbox, with that one socket | the reviewer, else you; only you for a credential agent's socket ($SSH_AUTH_SOCK, gpg-agent, a keyring, a password manager's agent, git's credential cache) and for an ssh control socket (one that an ssh process listens on, or one at a ControlPath of your ssh config; docs/permissions.md, "ssh control sockets"); refused inside a secret or efr's own dirs. A service manager's socket, such as $XDG_RUNTIME_DIR/systemd/private, is a privilege exit. A socket grant to a bus, a service manager's socket or a desktop socket, also through a link, makes the call fail to start |
desktop_ipc | hyprctl clients, hyprctl reload, swaymsg -t get_tree, i3-msg -t get_tree; hyprctl dispatch exec, xdotool type | the sandbox, with the one control socket of that desktop and its variable | the reviewer for a read or reload form; only you for the rest, and for a desktop socket that the model names |
bus | busctl, resolvectl, systemctl get-default, nmcli device status, iwctl station wlan0 show; nmcli radio wifi off; systemctl --user show-environment, notify-send --wait; hostnamectl, systemctl --failed and notify-send only while the bus filter is off (see "The bus filter"); needs.bus | the sandbox, with the whole bus socket | for the system bus: the reviewer, else you; only you for a form of nmcli or iwctl that changes something, because the bus filter has no rules for NetworkManager and iwd. For the session bus: only you, with the note the whole session bus can start programs outside the sandbox |
device | nvme smart-log /dev/nvme0n1 | the sandbox, with that one device | the reviewer, else you |
masked_read | read a project .env | the sandbox, with that one mask removed | only you |
destructive | git reset --hard, git clean -fd, rm -r of tracked files | the same sandbox | in auto, nobody when efr's snapshot holds every file that the part can change (Undo); else the reviewer when every target lies in a write root; else only you |
outside | crontab -l, df and findmnt while the mount view is off or behind a path or a wrapper, rustup toolchain install stable and the other rustup installs, or the model asks to run outside, such as gdb; with the proxy also ssh, mosh, autossh, and git fetch or git clone from an ssh remote | outside the sandbox | the reviewer; only you when a program of the line changed in this turn or lies in a write root, or when the line runs an interpreter on code that efr cannot read (python3 -c, sh -c, node build.js). An ssh line needs a message of yours that names the host, or a remote of your project; an ssh submodule URL, a rustup line that sets RUSTUP_* or CARGO_HOME, names a toolchain path or a custom toolchain, or runs rustup toolchain link asks only you. A git line, such as git fetch from an ssh remote, also asks only you when its git code is writable or not checked ("Git settings") |
privilege | sudo, doas, pkexec, yay, docker, podman | outside the sandbox, and you can type your password | only you |
persistence | ~/.zshrc, a Hyprland config; crontab, systemctl --user enable | outside the sandbox | the reviewer for a floor file, a unit, cron or linger that a message of yours names, after a copy into the home store ("Named changes of units, cron and linger"); else only you |
upload | git push, gh pr create, npm publish, cargo publish, curl -d | outside the sandbox | the reviewer when a message of yours names it; else only you. Only you for npm publish, cargo publish, cargo yank and cargo owner, which run project code or read .cargo/config.toml, and for a git or gh line whose git code a sandboxed command can write ("Git settings") |
synced_write | ~/Dropbox and the others of sandbox.synced_dirs | outside the sandbox | only you |
above_root | rm -rf .., rm -rf ~/p/old-app | outside the sandbox | the reviewer for one path that a message of yours names and that holds no floor; else only you |
secret | a read or write of a secret | never: denied | nobody |
config | a write of efr's config, of efr's own programs or of efr's own dirs | never: denied | nobody |
"Else you" means that the reviewer handed the question to you, or that it is off
(classifier.enabled = false), paused, or failed at each step of its chain.
A grant lives for one call. The next call asks again, unless an always rule covers it (see "Always-allow rules").
Desktop sockets. A desktop_ipc exit opens the one control socket of the desktop
that its program talks to, and sets the one variable that names it:
| Program | Desktop | Socket | Variable |
|---|---|---|---|
hyprctl | Hyprland | $XDG_RUNTIME_DIR/hypr/<signature>/.socket.sock | HYPRLAND_INSTANCE_SIGNATURE |
swaymsg | sway | $SWAYSOCK, a sway-ipc.*.sock in $XDG_RUNTIME_DIR | SWAYSOCK |
i3-msg | i3 | $I3SOCK, an ipc-socket.* in $XDG_RUNTIME_DIR/i3 | I3SOCK |
niri msg | niri | $NIRI_SOCKET, a niri.*.sock in $XDG_RUNTIME_DIR | NIRI_SOCKET |
wlr-randr, wtype, wl-copy, wl-paste | Wayland | $XDG_RUNTIME_DIR/$WAYLAND_DISPLAY, a wayland-* | WAYLAND_DISPLAY |
xrandr, xdotool, wmctrl, xprop, xdpyinfo, xclip, xsel | X11 | /tmp/.X11-unix/X<n> for a local :<n> | DISPLAY |
efr reads these variables from the terminal of your prompt, because efrd's own
environment goes stale when the compositor restarts. Only when the prompt sends none
does efrd use its own environment. efrd checks the socket at each call: it must be a
socket of yours with the place and the name of the table (an X server's socket in
/tmp/.X11-unix can also be root's), with no link on the way. So a variable that
project tooling such as direnv sets cannot name another socket of yours, such as the
session bus or a credential agent. The call never gets another socket of the
desktop, such as Hyprland's event socket .socket2.sock, and never its dir or its
log. When efr finds no such socket, the call does not run, and the model reads "efr
found no Hyprland session in the terminal of this prompt". An X server that needs an
X cookie ($XAUTHORITY in your terminal or in efrd's environment, or
~/.Xauthority) is refused for now: the cookie file stays
masked. ydotool talks to its own daemon, so its exit opens nothing.
The reviewer. In auto, the user's request is the permission. A reviewer model
decides most exits before any question: it allows an action that fits what you asked,
refuses an action that nothing you said relates to, and hands an unsure case to you.
It reads your messages and efr's facts about the exit (the line, the targets with
their class and whether the sandbox hides them as a credential store, the hosts, the programs, the counts, and for a file tool edit of a floor
file the new text, at most 8 KiB), and never the output of a command, a project file
or the model's reason. A longer edit asks you, so the reviewer never allows what it
saw only in part. efr applies its own rules to each allow: a critical risk never
allows, a high risk needs a message of yours that clearly authorizes it, a write
needs a message that names each target unless the target is a tool cache, build
output below a registered project or efr's trash, and a host that is not on the allow
list needs a message that names it, except a package fetch of a request to build,
install or update. The text of the line never changes this. A host is on the allow
list only with the proxy, only for a grant of that one host, and only on its port; a
grant of the whole network always needs the name. efr knows a host from the line (a
URL, a remote operand, the destination of ssh) and, for git fetch, git pull,
git push and git submodule update, from the remotes of the repository: every
fetch and push URL behind the remote name. A remote of your registered project's own
git dir that did not change since the conversation started counts as named by you
(project_remote); a repository that a command made does not count. A host that the
proxy refused in the last command or a job is a fact for the reviewer
(refused_by_proxy), never a name. A host that a script connects to has a fact only
after the proxy refused it.
The reviewer is the small model of your provider (classifier.model picks another),
then the turn's model at a low effort (classifier.fallback_to_turn_model), then you.
A model that is missing or refuses is skipped for the rest of the conversation; a
timeout (classifier.timeout_secs, 20 s for each step) is tried again at the next
exit. An error never allows and never counts as a refusal. Within a turn, the same
exit gets the same verdict without a second request.
reviewing: ./scripts/gen.sh (status row)
reviewer allowed: write ~/.local/share/app for this call (low risk; you asked to run the generator)
ยท $ ./scripts/gen.sh
โ 1.4s
- While it works, the status row shows
reviewing: <command>. - An allow or a refusal is one dim line before the call. A refused call does not run, and the model reads that the reviewer refused it and must not work around it.
- An unsure verdict asks you. The question then has the dim row
reviewer: unsure; <reason>. - The reason is the reviewer model's text. efr shows it safely, as it shows every text of a model.
- After the reviewer refused 3 actions in a row, or 10 in one turn, it pauses for the
rest of the turn:
reviewer paused for this turn: it refused 3 actions in a row; efr asks you for the rest. Each later exit asks you. An allow or your answer starts the count in a row again. When you are away, the turn stops instead:auto stopped this turn: the reviewer refused 3 actions in a row and nobody could decide. efr historyshows each verdict, andefr history --verboseadds the model, the step of the chain, the time and the models that the chain skipped.
When you are away. efr counts you away from a conversation after
presence.away_after_minutes (10) without input, or after efr away or ,away. Input
is a prompt, a steer, an answer or keys for the hidden shell; keys in the input row
count only when you send them. The status row of a running turn shows away. Your
next input ends it. While you are away, a question waits only presence.away_wait_secs
(300): approval expired: nobody answered while you were away; the call did not run.
A question that already waits when you leave gets the same wait, from the moment efr
sees that you are away (it looks every 15 seconds).
The model goes on without the call and names it in its answer. An expired question is
no refusal.
An exit that runs outside the sandbox runs with your full rights: your files, your
secrets and the network. So efr accepts only a narrow line for it. One simple command
carries the exit, and every other command of the line must be a read-only helper that
cautious allows, such as echo, or a sudo -k (also -K, --reset-timestamp)
that only forgets the cached password, so sudo -k; sudo true asks once.
sudo -v && ./helper gets no question; the model reads "a command that must run
outside the sandbox must run alone; run the other parts in a separate call", and you
see refused: a command that must run outside the sandbox must run alone. The
processes of such a run stop when the call ends.
The question shows the whole line, what leaves the sandbox, and how the call runs:
? allow outside the sandbox
โ sudo ./scripts/setup.sh
โ runs with your full rights: files, secrets, network
โ why: sudo (you may need to type your password)
โ programs: sudo, ./scripts/setup.sh ~/p/app/scripts/setup.sh (untrusted: written in the sandbox; in a write root, changed this turn)
โ efr: only you can allow this
โ y allow ยท n deny
? allow full network for this call
โ nslookup example.com
โ why: network
โ y allow ยท n deny
- The title says what a "yes" allows. A command of several lines shows each line on
its own row. A line wider than the screen goes on in the next row, so nothing of
what runs is cut: after a space the row ends in a
\and the next row is indented; inside a word the row ends in aโฉand the word goes on at the same column. - When you answer, the question gives its place to one line,
โ allowedorโ denied, and the call shows once, in its own block, also when you denied it. - Only the facts that matter most stand out in the
warningcolour: the title, the full-rights line and theuntrustedmark. What runs is in thecodecolour. The other facts are dim. programs:names every program word. A program in a write root, or one that changed in this turn, also shows the path it runs and is markeduntrusted: written in the sandbox: the sandbox wrote it, so it can do anything with your rights. A word that runs no program shows(not found).- Lines that start with
efr:are facts that efr found itself. you may need to type your passwordshows for a line that can wait for input at the terminal, such assudo, and for a line that may ask for a password through ssh:rsyncto another host or with a remote shell (-e,--rsh,RSYNC_RSH),scp,sftp,sshfs,ssh-copy-id,mosh,autossh,ssh-add,ssh-keygenwithout-N, andgitover the network, also withGIT_SSH_COMMAND,GIT_SSHorcore.sshCommand. It shows only when the call runs outside the sandbox. A question about a call that runs in the sandbox never names a password ("Input in the sandbox").- Such a call outside the sandbox runs past the model's timeout while a terminal that
can type your answer follows the turn, up to
shell.interactive_timeout_minutes(60), whoever approved it: you, your rule, the reviewer or full mode. So the password prompt of anrsyncwaits for you, not for the model's 30 seconds. With nobody to answer, the call answers the model at its timeout, and the command goes on in the hidden shell (see A busy hidden shell). the model says:is the model's reason fromneeds. It is the model's text; treat it as a claim, not as a fact.- When the model asks to run outside the sandbox, but each other exit that efr finds
in the line can run in the sandbox with a grant, a fact says so, for example
efr: a grant of the system bus covers what the line shows; the model asked for outside. The question still asks about what the model asked for. efr reads only the line, so a script or a build can need more than the line shows. - A refused exit does not run. The model reads that you denied it.
- A floor refuses a call with no question, and the call's result says why, such as
โ refused: efr's config (floor). - A call that fails in the sandbox ends with its exit code and its time, such as
โ exit 2 ยท 1.5s, and no word about the sandbox: every shell call ofautoruns there. A turn in which a call ran in the sandbox ends with one dim line that says where the sandbox can write, such assandbox: writes in the project, $SCRATCH, private /tmp; no network. When the turn changed files in projects, the line names them first:sandbox: wrote in app and lib; could write $SCRATCH, private /tmp; no network. When a call of the turn ran with the proxy, also a background job that the turn started, the line saysnetwork through efr's proxyin place ofno network. When a call of the turn had the whole network (a raw network program or alocalhostport), the line saysfull network. When a call of the turn had the network view, the line ends with; host network view (no sending). - Three refusals in a row without a person (by a floor) stop the turn. A refusal of the reviewer does not count here; it has its own pause (above).
efr history --verbose shows the record of each exit: the line, its targets, hosts and
programs, and how it was judged (by you, a floor or the reviewer). Without a conversation id, it shows the newest
conversation of this terminal, else the newest of all, and says which.
The host's network view and mounts
Some system work only reads your machine: what listens on a port, the traffic
counters, how full the disks are. In the sandbox these tools would show the
sandbox's own network and mounts. So efr gives such lines a view of the host, with no
exit and no question. docs/adr/0011-netview-and-mount-view.md
records the decision.
The host's network view
A line that only reads your network runs in the sandbox with Grant::NetView. The
call keeps your network namespace, so ss, ip and /proc/net show your real
sockets, links, counters and routes. The call cannot send anything:
| What | In a network view call |
|---|---|
| Sockets | seccomp allows only AF_UNIX and AF_NETLINK. Every other family fails at socket() with EAFNOSUPPORT: TCP, UDP, IPv6, packet, raw, Bluetooth, vsock and each family that a later kernel adds. So a DNS query fails too. |
| Netlink | only NETLINK_ROUTE (for ip) and NETLINK_SOCK_DIAG (for ss). Every other protocol fails with EPROTONOSUPPORT, so iw stays a host_view exit. Without a capability, these two protocols cannot change a link, an address or a route. |
listen, pidfd_getfd | fail with EPERM, so a call cannot wait for a host program on an abstract socket name, or take a socket from another process. |
| TCP | Landlock also refuses TCP bind and connect, a second layer behind seccomp. |
| Unix sockets | as in every call: no connect to a socket path that the call did not make, and no connect or send to an abstract name outside the sandbox. |
ss -p | names only the processes of the call. Landlock hides the file descriptors of every other process. |
| Package managers | get the offline hints, as in every call without the network or the proxy. |
Which lines get the view (efr-permissions/src/exits/view.rs): each simple command
of the line is one of these:
ss, but not with-K,--kill,-D,--diag,-Nor--net;- a read form of
ip(addr,link,route,neigh,rule,maddr,addrlabel,netconf, with no verb orshow,list,ls,lst,get), but not with-batch,-netnsor-all, or a prefix of them; nstat, but not with-dor--scan;cat,head,tail,greporwcof a file below/proc/net(also/proc/self/netand/proc/<pid>/net);- a text helper:
grep,head,tail,wc,sort,uniq,cut,tr,column,cat,echo,printf,date,sleep,:,true,tee.
watch, timeout and a while loop around these are allowed, so watch -n 5 ss -ti
and while :; do ss -ti; sleep 5; done get the view. No interpreter (awk, python),
no find, git or xargs, and no netstat. A line that also does other work, such
as ss -tln; make test, keeps the host_view exit.
This rule only picks the lines that fit. It is not a security layer. A call with the
view can still run any code: a function or an alias named ss that an earlier call
defined, a zsh module such as zsh/net/tcp, a bug in a helper. The hidden shell
drops a function of your rc files that has the name of a program that a rule trusts,
such as ss, but the sandbox state of an earlier call can define one. seccomp,
Landlock and the refusal of listen carry the whole load. The rule keeps lines that
start local servers out of your network namespace, because such a call loses the
sandbox's private loopback.
When the line also has an exit, the launch gets the view only beside write and
unmask grants, which do not depend on the network namespace. With every other
grant, the view drops: the network grant needs sockets that the view refuses, and a
device such as /dev/net/tun could send frames with no socket() call. So
ss -tln; curl https://x is a host exit with the full network, and
cat /proc/net/dev /dev/net/tun is a device exit with no view.
network.netview = false turns the view off: these lines are host_view exits again.
The view is also off when the probe's self-test of it failed (see "When the sandbox
cannot run"). efr status shows netview yes, netview no with the reason, or
netview off (network.netview = false) when you turned the key off. A
background job keeps its view up to its time limit, also after you turn the key off or
a later probe fails: its seccomp filter is in place, and the kernel does not change
under it. job_stop or efr jobs stop ends it.
nmcli and iwctl talk to NetworkManager and iwd over the system bus, not through
netlink. They are bus exits: the reviewer decides a read form, such as
nmcli device status, nmcli general status or iwctl station wlan0 show, and only
you allow every other form, such as nmcli radio wifi off or
iwctl station wlan0 connect X. A background job with nmcli or iwctl is refused,
as every job that needs the whole bus is.
The host's mounts
A line that runs df or findmnt by its bare name, where the shell itself finds the
program (df -h, LC_ALL=C df, time findmnt), gets a snapshot of your mounts with
no exit. efrd runs in your mount namespace, so it reads its own
/proc/self/mountinfo and measures each mount outside the sandbox. It writes two
files into the call's dir, mounts (the mount table) and mounts.json (the sizes),
and the sandbox binds them read-only next to the launcher. In the call:
dfwith options only prints the snapshot in the format of GNUdf. It knows-a,-B,-h,-H,-i,-k,-l,-P,-T,-t,-x,--total,--output,--syncand--no-sync. Any other option,--, or a path runs the realdf, which shows the sandbox's file system:df -h /tmpshows the call's private/tmp, which is where a write really goes. The rows follow upstream GNUdf, which listsdevtmpfs. Some builds hide it: on Ubuntu 24.04,dfin a call shows one more row,/dev, thandfoutside.findmntreads the snapshot as its table (--tab-file).findmnt -Dand-Iprint the snapshot's sizes. With an option that picks a table of its own (-F,-N,-k,-s,-p,-x,-m), it runs the realfindmnt.- A
dforfindmntfunction of your shell wins over these. They exist only on the line itself: a script orsh -c 'df'shows the sandbox's mounts. - A path or a wrapper runs the real program, which shows the sandbox's mounts. So
/bin/df,command df,exec df,timeout 10 df,env LC_ALL=C df,watch dfandfind -exec dfstayoutsideexits, which show the true view after a review. lsblkshows your real devices; itsMOUNTPOINTScolumn shows the sandbox's.
The rules of the snapshot:
- No sizes for the dummy file systems (
proc,sysfs,cgroup2,autofsand the others), so adfnever mounts an automount share. - No sizes for the mounts of another user (below
/run/user/<uid>, or a FUSE mount with anotheruser_id), and none for a mount at or below a mask or a secret of the call, such as$XDG_RUNTIME_DIR/docor an encrypted store. Anunmaskgrant shows the sizes of its path. - One helper process,
efr-sbx statvfs, measures every other mount, one thread per mount. After one second, efrd kills it and does not wait for it, so a hung NFS or FUSE mount never holdsdf. Such a mount shows-, and efrd does not measure it again for a minute. At most one helper runs at a time, and a snapshot serves calls for two seconds. - A background job gets the snapshot of its start and keeps it.
- When the snapshot fails, the call runs with the sandbox's mounts and the model reads
the mount view failed; df shows the sandbox's mounts.
At start, efrd compares its mount namespace with the one of its parent, the systemd
user manager. When they differ, for example because a unit drop-in sets
PrivateTmp, ProtectHome or ProtectSystem, efrd's view of the mounts is not
yours: the mount view is then off, and df and findmnt are outside exits, which
show the true view after a review. A test of efr fails when systemd/efrd.service
gets such a key.
Every contained call could already list every mount of the machine through
/proc/<pid>/mountinfo, also the mounts of other users and containers. A new procfs or
hidepid would hide them, and both hide ps. The mount view adds the sizes of your
own mounts.
The network proxy
With network.mode = "proxy" (the default), a contained command reaches the hosts of
your allow list through efr's network proxy, with no review: the package registries and GitHub
by default. So cargo build with a new dependency, npm install and pip install
run at once. Every other route stays closed. With network.mode = "none", a contained
command has no network, as before phase D2.
docs/adr/0012-network-proxy.md records the decision.
The path of a connection
- The call keeps its own network namespace, with only loopback. Before the command
starts, the launcher starts a small bridge in the call,
efr-bridge, which listens on127.0.0.1:3128. - efr sets the proxy variables of the call to
http://127.0.0.1:3128:HTTPS_PROXY,HTTP_PROXY,https_proxy,http_proxy,ALL_PROXY,all_proxy,CARGO_HTTP_PROXY,npm_config_proxy,npm_config_https_proxy,YARN_HTTP_PROXY,YARN_HTTPS_PROXYandPIP_PROXY.NO_PROXYandno_proxyarelocalhost,127.0.0.1,::1, so a server that a test starts in the call stays direct. These variables win over a proxy in your own tool config, such as~/.cargo/config.toml. Node'sfetchuses them only withNODE_USE_ENV_PROXY=1, which efr sets, so Node prints an "experimental" warning on stderr. curl, cargo, git over https, npm, pip, uv, go, bun and pnpm were tested on 2026-10-13. A tool that does not read these variables fails closed: it cannot resolve the host. - The bridge copies each connection to the call's proxy socket. efrd binds this Unix
socket for the call (
$CALL/proxy.sock, mode 0600), and the launcher binds it read-only into the call at$XRT/efr-proxy.sock. The command itself cannot connect to that socket: Landlock givesEACCES. Only the bridge can. - efrd's proxy reads the request, checks it, resolves the host, connects, checks the TLS server name, and then copies the bytes both ways.
The bridge starts with two plain process spawns, a starter and a server, and runs in
its own session. Before it serves, the server closes every other file descriptor,
names itself efr-bridge, turns off core dumps and /proc access to it, and applies
its own Landlock domain (no write right, a connect only to the proxy socket, no
signal out) and its own seccomp filter (only Unix sockets; no bind, listen,
execve or ptrace). Then it tells the starter that it is ready. When a step fails,
the bridge does not serve, and the call fails to start with a setup error. The bridge
adds about 0.6 ms to each call. The bridge is no job of the call's shell, so the end
of a call does not list it as a stopped background process, and the stop of a
background job spares a process with its name. The end of the call's pid namespace
ends it.
What the proxy checks
For each CONNECT request, in this order:
- The request head must come within 10 seconds and 8 KiB. A control character gives 400.
- An IP address as the host gives 403, unless
[[network.hosts]]lists it. - When the call's upload budget is spent, a new tunnel gets 403.
- Port 80 means plain HTTP: a tunnel to a port that only an
httpentry or a port 80 grant allows gets 403. - The lists: the deny list wins, then the allow list, then the host grants of the call. A host that is not on a list gets 403, with no DNS lookup. So a name that is not listed never reaches a DNS server.
- The proxy resolves the name (5 seconds). Every address of the name must be a
global address: not loopback, private, shared (CGNAT), link-local, unique local,
multicast, documentation or another special-purpose block, and not an address of
your machine or of one of its networks. The proxy unwraps the IPv4-mapped,
IPv4-compatible and NAT64 forms first. A
private = trueentry may resolve to an address of your LAN, never to loopback, link-local or your machine. - The proxy connects only to an address that it checked: at most 4 addresses, 10 seconds each and 15 seconds in all.
- After
200 Connection established, the first bytes must be a TLS ClientHello (16 KiB, 10 seconds), and its server name must be the host. A hello with no server name, bytes that are not TLS, and a hello that asks for Encrypted Client Hello cut the tunnel before any byte of the client leaves. - Then the proxy copies the bytes, under the upload budget.
Plain http:// works only for an http host: an entry with http = true, an
http:// pacman mirror, or a host grant on port 80. It allows only GET and HEAD.
The proxy writes a new request head with a fixed list of headers, and reads no byte
after the head, so one connection carries one request. When the server cannot be
reached, the command gets 502 with a short reason, such as no such host, refused
or timed out.
The upload budget (network.max_upload_bytes, 4 MiB) counts every byte that the
command sends after each CONNECT head, the TLS hello included, over every connection
of the call. A background job counts for its whole life. When the budget is spent, the
proxy cuts the connection and refuses new ones.
The proxy checks only the first TLS hello. A second hello after a HelloRetryRequest, a
TLS 1.2 renegotiation and the :authority of HTTP/2 are not checked.
The allow list
network.allow holds presets, host names and *.suffix entries. The default holds the
six presets:
| Preset | Hosts |
|---|---|
@crates | crates.io, index.crates.io, static.crates.io |
@npm | registry.npmjs.org, registry.yarnpkg.com |
@pypi | pypi.org, files.pythonhosted.org |
@go | proxy.golang.org, sum.golang.org |
@github-read | github.com, codeload.github.com, objects.githubusercontent.com, raw.githubusercontent.com, release-assets.githubusercontent.com |
@pacman-mirrors | the hosts of the Server = lines of /etc/pacman.d/mirrorlist and of each file that an Include = line of /etc/pacman.conf names; empty when the files do not exist |
An entry of allow allows port 443. Write a name in lower case and in its ASCII
(xn--) form; * alone and a suffix of one label, such as *.com, are refused. efrd
reads the pacman files at start, at a reload, and again when one of them changed (at
most once a minute). network.deny holds entries that no command reaches; it wins
over the allow list and over every host grant. [[network.hosts]] adds a host with
options, and only an editor changes it:
[[network.hosts]]
host = "mirror.lan"
port = 8080
http = true # plain GET and HEAD too
private = true # it may resolve to an address of your LAN
To add a company registry or a GOPROXY host, add its host to network.allow (or to
[[network.hosts]] for another port), and set the registry in the tool's own config
as usual. The proxy variables of the call stay in front of it.
A change of allow, deny or [[network.hosts]] reaches every running call at once.
A change of mode applies to the next call; a running background job keeps its
proxy until it ends. network.mode = "none" and a running job: efr jobs stop ends
it.
Hosts that are not on the list
- A host that the line shows, such as the URL of
curlorwget, or the host of an https git remote, is ahostexit with a grant of that one host and port for that one call. The reviewer may allow it only when a message of yours names the host. The next call asks again. - A host that the line does not show, such as a host in a script, gets 403. The
command's output then has a note, such as
[efr: the network proxy refused other.test:443: not on the allow list. To reach a host, call shell again with needs.hosts.], and the model can ask for the host withneeds.hosts. The record of that next exit says that the proxy refused the host (refused_by_proxy). This is a fact, never your permission. A host that a deny holds (network.deny, or a credential of "Credentials", below) gets the noteefr denies this host for this command ... A host grant does not help, and it never counts asrefused_by_proxy. - Programs that do not use HTTP keep the whole network after a review:
nslookup,dig,host,drill,ping,traceroute,tracepath,mtr,nc,ncat,netcat,socat,telnetandftp. So does aneeds.hostsentry oflocalhost, a private or link-local address, or a port other than 443 and 80. This is the route for a service on your machine or your LAN, and for a client that does not read the proxy variables, such aspsql,redis-clior Java. Such a host never counts as on the allow list, so the reviewer needs a message of yours that names it. A dev server that your browser must reach does not need this route: the model starts it as a background job, and efr forwards its port (see "Ports of a job"). ssh,mosh,autosshand git over ssh (a fetch or clone from an ssh remote) run outside the sandbox with your ssh keys. The record names the host, and the reviewer may allow it only when a message of yours names the host, or when the remote belongs to your registered project and did not change since the conversation started. An ssh URL of.gitmodules, which a command can write, always asks you. A line that can send ssh to another host first or run a command on your machine always asks you too:ssh -F,-Jor-W;ssh -owithProxyCommand,ProxyJump,HostName,LocalCommand,PermitLocalCommand,KnownHostsCommand,MatchorInclude;mosh --sshor--client;git -c,--config-envor--exec-path;git clone -c,--config,--templateor--recurse-submodules; and a line that sets aGIT_*,SSH_*orAUTOSSH_*variable, such asGIT_SSH_COMMAND.git pushand the other uploads stayuploadexits outside the sandbox, as before. The reviewer now sees every host behind the remote name. Agit pushwhose host efr cannot tell, such asgit --git-dir=... push, a push after acd, or a push to a local path, asks you, and so does a push in a line that sets aGIT_*orSSH_*variable.- A line that also reads your network, such as
ss -tln; curl https://x, keeps thehost_viewexit with the whole network: the network view never joins a proxy call.
Refusals
A command that the proxy refused shows a dim line under its result, such as
network: blocked other.test:443 (not on the allow list), and the model reads a note
with the reason. A background job lists its refusals in the line of its end. The
proxy keeps at most 32 records for each call, one for each host, port and reason,
with a count; and N more counts the rest.
| Reason | Why |
|---|---|
not on the allow list | the host is not on a list, and no grant of the call names it |
denied for this command | the deny list holds the host, or efrd denies it for the call because a file or a credential helper can give the call a token for it (see "Credentials"); no grant opens it |
a private address | the name resolves to a local or private address |
the TLS name differs from the host | the server name of the TLS hello is not the host |
not TLS | no TLS hello with a server name, or a hello that asks for Encrypted Client Hello |
ip address | the command named an IP address |
a method that is not allowed | plain HTTP other than GET and HEAD, or a tunnel to port 80 or to a port that only an http entry allows |
bad request | a malformed request head |
over the upload limit | the call sent more than max_upload_bytes |
too many connections | more than 64 open connections in the call |
Credentials
The proxy lets a command reach an allowed host, so efr keeps credentials out of the call:
-
The read masks hide the credential files of git, the registries and the package tools (see "Read masks").
-
The environment filter drops
GIT_CONFIG_PARAMETERS,GIT_CONFIG_COUNT,GIT_CONFIG_KEY_*,GIT_CONFIG_VALUE_*, names that end in_AUTH, and each variable whose value is a URL with a password, such asPIP_INDEX_URL=https://user:token@....sandbox.env_keepcan keep the last two kinds;efr statusandefr sandbox checkthen warn about each kept name that looks like a secret. -
A token in a file that the sandbox must read cannot be hidden line by line. So when the project's
.npmrc,.yarnrc.yml,.yarnrc,bunfig.toml,.cargo/config.tomlor.cargo/config, or the git config of the project or of the command's directory (with~/.gitconfig, the XDG file and their includes, also outside a project) holds a password or a token for a host, efrd denies that host for the call. The output says why:[efr: efr blocks registry.npmjs.org for this command: ~/p/app/.npmrc holds a credential for it. Move the credential to a file that the sandbox hides, such as ~/.npmrc.]. A githttp.extraHeaderwith no URL denies every remote host and the hosts of@github-read. -
git runs the same credential helpers in the sandbox, so a helper that can find a token there denies its hosts. A helper for one site (
credential.https://git.example.com.helper, also with a path) denies only that site; a helper with no site, or with a site that efr cannot read (a*or nohttps://), denies the hosts of every remote of the repository.Helper What efr does !gh auth git-credential(gh auth setup-gitwrites it)no deny while the sandbox hides gh's token: the hosts.ymlof gh's config dir ($GH_CONFIG_DIR, else$XDG_CONFIG_HOME/gh, else~/.config/gh, from the hidden shell) lies under a read mask, nosandbox.env_keeppattern keepsGH_TOKEN,GITHUB_TOKEN,GH_ENTERPRISE_TOKENorGITHUB_ENTERPRISE_TOKEN, and the command has no grant of the whole session bus. Else it denies its hosts, and the note says whylibsecret,store(its own files),cache,manager(Git Credential Manager)no deny: the keyring needs the whole session bus, ~/.git-credentialsand~/.gcmare secrets, and the cache's socket needs a socket grant that only you approveany other !command,store --filedenies its hosts any other program, such as oauthorpass-git-helperdenies its hosts, because a credential helper that efr does not know can hand out a token; the note names it -
hub's
~/.config/hub, Copilot's~/.config/github-copilotand Git Credential Manager's~/.gcmare secrets, so a command in the sandbox cannot use their GitHub tokens while github.com is open.
When the proxy closes
The proxy socket of a foreground call closes when the command ends; open connections get 200 ms to finish. A call that runs past its timeout keeps its socket until its result comes or its dir goes (efrd looks at each prepare of the conversation and once an hour). A background job keeps its socket until it ends. An efrd restart ends every job; a foreground call that runs past its timeout loses its network then.
The probe checks the bridge at each start (see "When the sandbox cannot run"). When
the check fails, contained commands have no network, and efr status says so.
What the proxy cannot stop
- The proxy cannot see inside TLS. A command can send data to a host on your allow list, with no credentials, up to the upload budget.
- The Go module proxy fetches a missing module from its origin host, so a request
through
@gocan carry data to another host. A*.suffixentry lets a command pick a DNS name below the suffix, which the proxy resolves. - A CDN that serves another site by the
Hostheader inside TLS widens what an allowed host reaches. In the probe, Fastly answered 421 and Cloudflare 403. - A socket grant that reaches the network goes around the proxy: systemd-resolved's varlink socket, nscd, the Docker and Podman sockets, tailscaled and a SOCKS socket.
- Tools that send an Encrypted Client Hello, also as a decoy, such as headless Chrome, Electron and Playwright, cannot fetch through the proxy.
The bus filter
Read-only system queries and desktop notifications run in the sandbox with no
question. Examples: hostnamectl, timedatectl, localectl, systemctl --failed,
systemctl status nginx.service, systemctl --user --failed,
loginctl list-sessions and notify-send 'build done'. Such a command reaches the
system or the session bus only through efr's bus filter, xdg-dbus-proxy, which lets
through only a fixed list of methods. The filter runs while dbus.proxy is true,
the default. When dbus.proxy = false turns it off, or xdg-dbus-proxy is missing,
these commands need the whole bus again: the system bus queries go to the reviewer,
and the session bus commands ask you.
The whole session bus is always your own question, never the reviewer's. On that bus,
the user manager (systemd --user) starts any program outside the sandbox
(StartTransientUnit), and an unlocked keyring (the Secret Service,
org.freedesktop.secrets) gives every secret with no prompt. The question says
the whole session bus can start programs outside the sandbox. A socket need of the
bus counts too: /run/user/<uid>/bus, and the socket of DBUS_SESSION_BUS_ADDRESS
in efrd's environment, such as one of dbus-launch below /tmp, also through a
link. efr does not first look for a keyring on the bus, because a keyring that starts on demand has no owner
until a program calls it. The whole system bus keeps its approver.
docs/adr/0013-bus-proxy-and-always-allow.md
records the decision.
Which lines get the filter
A line gets the filtered bus when at least one of its simple commands is one of these
read forms, with no option that names another host, machine, root or image (-H,
--host, -M, --machine, --root, --image, --image-policy):
| Program | Read forms |
|---|---|
systemctl, systemctl --user | no verb, status, list-units, list-unit-files, list-sockets, list-timers, list-jobs, list-dependencies, is-active, is-failed, is-enabled, show, cat, and --failed |
loginctl | no verb, list-sessions, list-users, list-seats, show-session, show-user, show-seat, session-status, user-status, seat-status |
hostnamectl, timedatectl, localectl | no verb, status, show; also timedatectl timesync-status and show-timesync |
notify-send | every form except one that waits for the notification to close or for an action (-w, --wait, -A, --action): the filter lets no signal back, so such a form would wait for ever |
The rule picks the lines that fit. It is not a security layer: in
systemctl --failed; ./x.sh, the script gets the same filtered bus. The filter, not
the rule, decides what a call can do on the bus. A systemctl form gets the system
bus, a systemctl --user form and notify-send the session bus. Every other bus form
keeps its bus exit (see "Exits"), such as systemctl get-default,
notify-send --wait, busctl, resolvectl, coredumpctl, nmcli and iwctl. A systemctl that changes
something, such as systemctl restart nginx, stays a privilege exit, and
systemctl --user enable a persistence exit.
What the filter lets through
The built-in rules name each method by its full name and its path. These are the read calls that efr recorded for the tools above:
| Bus | Service | Methods |
|---|---|---|
| system | org.freedesktop.hostname1 | Properties.Get and GetAll at /org/freedesktop/hostname1 |
| system | org.freedesktop.systemd1 | Properties.Get and GetAll, and Manager.ListUnits, ListUnitsByPatterns, ListUnitFiles, ListUnitFilesByPatterns, GetUnitFileState, GetUnit, GetUnitByPIDFD and ListJobs at /org/freedesktop/systemd1; Properties.Get and GetAll at each unit |
| system | org.freedesktop.login1 | Manager.ListSessionsEx, ListSessions, ListUsers, ListSeats, GetSession, GetUser and GetSeat; Properties.Get and GetAll below /org/freedesktop/login1/ |
| system | org.freedesktop.timedate1, locale1, timesync1 | Properties.Get and GetAll at the service's own path |
| session | org.freedesktop.systemd1 | the same Manager methods, and Properties.Get and GetAll at each unit; no property of the manager itself, so its Environment stays hidden |
| session | org.freedesktop.Notifications | Notify, GetServerInformation and GetCapabilities at /org/freedesktop/Notifications, the calls of notify-send |
Every other method, name and path fails with Access denied (or ServiceUnknown for a
name that the filter hides), and the service never gets the call. So
systemctl --user status with no unit and systemctl --user show-environment fail.
systemctl status <unit> works without the process tree: GetUnitProcesses is not
in the list, because it shows the command lines of the processes of your machine,
which the sandbox hides. You can add it (see [[dbus.allow]] below).
systemctl --user reaches the session filter through a function of the sandbox's
shell. Before it runs systemctl, the function unsets XDG_RUNTIME_DIR, because
systemd otherwise looks only for the user manager's private socket. A script that runs
systemctl --user itself does not get the function, and fails.
A call that the filter refuses
The command fails at that call, as it would with Access denied from the real bus.
The model reads one note with the refused call as data, such as [efr: the bus filter refused a call: bus=system name=org.freedesktop.systemd1 member=org.freedesktop.systemd1.Manager.StartUnit path=/org/freedesktop/systemd1. For the whole bus for one command, call shell again with needs.bus.]. While you follow the
turn, the view shows bus: refused system org.freedesktop.systemd1 Manager.StartUnit. The record of the call and the end of a job keep the refused
calls, at most 16 of them with a count.
When the model then asks for needs.bus, that is a bus exit for the whole bus, for
one call. For the system bus the reviewer or you decide; for the session bus only you
decide. The next filtered call is filtered again.
[[dbus.allow]]
[[dbus.allow]] adds methods to the built-in rules. It never replaces them. Only an
editor changes it; the settings tool and the a answer cannot.
[[dbus.allow]]
bus = "system"
name = "org.freedesktop.systemd1"
call = ["org.freedesktop.systemd1.Manager.GetDefaultTarget@/org/freedesktop/systemd1"]
Each call entry is IFACE.MEMBER or IFACE.MEMBER@PATH, and a PATH may end in
/*. efrd refuses an entry at start and at a reload, with its index, when it holds
*, IFACE.*, a path-only rule or an empty call list, when its name is not a
well-known bus name, a unique name (:1.42) or the bus driver, when a call is
org.freedesktop.DBus.Properties.Set, or when the name is a protected service:
secrets, a portal, polkit, Flatpak, kwallet or dconf. efr config check shows the
same errors.
Two more checks keep an entry to reads where efr can tell:
- For
org.freedesktop.systemd1,login1,hostname1,timedate1,locale1andtimesync1, an entry may add onlyProperties.Get,Properties.GetAll, or a member whose name starts with the wordGet,List,CanorLookup, on every interface. - For every other service, efr refuses a member that starts with a word that changes
the machine, such as
Set,Start,Stop,Restart,Kill,Enable,Mask,Reboot,PowerOff,Suspend,Lock,Inhibit,Mount,CreateorActivate, with or without capitals. This list is a floor, not a full guard: for these services efr cannot tell a read from a write. You own each method that you add.
An added method does not make a new read form: systemctl get-default still has its
bus exit. The method helps a script on a line that already has the filter.
How the filter runs
- efrd starts one
xdg-dbus-proxyfor each filtered bus of a call, before the call starts, and ends it after the call. A call needs at most two, and most calls need none. A start takes about 1.5 ms. A background job keeps its filters for its whole life, with the rules of its start: a reload that removes a[[dbus.allow]]entry or setsdbus.proxy = falsereaches only new calls and new jobs. - The proxy runs under
efr-sbx dbus-proxy, with its own Landlock domain, a seccomp filter and an fd limit: it reads only its program and its libraries, connects only to the two real buses, makes its sockets only in the call'sbus/dir, and has no TCP. The call sees its socket at/run/dbus/system_bus_socket(over an empty/run/dbus) or at$XDG_RUNTIME_DIR/bus, withDBUS_SYSTEM_BUS_ADDRESSorDBUS_SESSION_BUS_ADDRESSset. - A proxy that cannot start fails the call with a setup error. The call never gets the
whole bus instead. A proxy that stops during a call gives the note
the bus filter stopped during the command. - On a machine with no session bus, a session read form runs with no bus, and the
model reads
[efr: no session bus on this machine; the bus filter did not run.]. - efrd uses
xdg-dbus-proxyonly when root owns it and its directory, as for every program that efrd runs outside the sandbox. The Arch packages name it as an optional dependency.
What the filter cannot do
- It checks no arguments.
Properties.GetandGetAllon an allowed path read every property there. The properties of a session unit can show the environment of a transient unit, such as one thatsystemd-run --user -Estarted, which the sandbox hides. - A client can send broadcast signals with no rule. The sender is the client's own unique name, so it cannot pose as a well-known name.
- The bus and polkit see the proxy, which runs in efrd's session, not your seat
session. The built-in rules hold no method that polkit guards;
hostnamectlasks for two such methods (the product UUID and the serial), and the filter refuses them, as the real bus does. - A rule gives its name the right to be started by the bus. So the bus can start a
listed service by activation.
~/.local/share/dbus-1is a floor, so a sandboxed command cannot plant a service file there. - The proxy itself runs outside the sandbox with an unfiltered connection to the real bus for each client. A memory bug in its parser reaches your full rights on that bus. The wrapper limits files, network and system calls, not what the proxy sends on the bus.
Always-allow rules
At an exit question that offers it, a saves a standing answer. The card shows the
key with the rule in words, such as a always: write under ~/notes. a does not
answer at once: efr shows save rule "write under ~/notes"? y/n, and only y saves
the rule and runs the call. Any other key goes back to the question. A key that you
typed before the confirm line showed never answers it.
The rule goes to [[sandbox.allow]] at the end of config.toml, and efr keeps every
comment and blank line of the file. A file with no [sandbox] table gets no empty
[sandbox] line above the rule. Then the call runs as with a "yes", and the view
shows saved as sandbox.allow[2]: write under ~/notes. When efr cannot save the rule
(the file is read-only, it changed three times while efr wrote it, or the rule fails a
floor check), the call still runs once, and the view shows rule not saved: <reason>; the command ran once.
A later call that the rules cover runs with no question and no review, with the same
widening that a "yes" gives. The view shows a dim line, such as allowed by your rule sandbox.allow[2]: write under ~/notes, and efr history shows judge: always. This
works also while you are away, in background jobs and in turns that a job notice
started: a rule is your standing answer and reads no message.
[[sandbox.allow]]
kind = "host"
host = "example.com"
port = 443
[[sandbox.allow]]
kind = "write"
under = "~/notes"
project = "~/p/app"
[[sandbox.allow]]
kind = "socket"
path = "~/.mpd/socket"
| Kind | Covers | Refused or disabled |
|---|---|---|
host | a host exit whose grants are each that one host and port through the proxy (port is 443 when you leave it out) | a *.suffix, an IP address, localhost, a host of network.deny, a bad host name. A host rule never covers the whole network, so with network.mode = "none" it does nothing |
write | a contained write exit whose binds all lie at or below under; with project, only in turns of that project | under outside your home dir, at or above ~ or a write root; at or below a floor, a mask, a secret, efr's own dirs, projects.toml, a synced dir or a .git dir; a dir that holds a built-in floor of your home, such as ~/.config; a dir that holds more than one project; a link on the way |
socket | a socket exit for that one socket, which is not a link | a service manager's socket, docker.sock, podman.sock and containerd.sock, a credential agent, a desktop socket, a bus socket, $XDG_RUNTIME_DIR/systemd/*, a path below a read mask, a path in efr's dirs or in a write root |
A rule covers a call only when it covers every exit of the call, and only when each of these holds:
- The exit is one that the reviewer may judge. So a rule never covers a privilege,
persistence, upload, desktop, masked-read, synced-folder, bus or device exit, a delete
in a cache,
outside, or a file tool such aswrite_file. - The call runs in the sandbox, and each grant is narrow: one host and port, a contained write bind, one socket.
- Each path matches by its real path, after every link, as efrd finds it before the call. A path that efrd could not resolve is not covered, and neither is a socket that is a link.
- No floor, synced dir or project lies at or below a dir that the call would open,
also a floor that does not exist yet. A grant of one file stays coverable, but
aoffers no rule for a new file that the call makes: once the file exists, a later write binds its dir, and a rule of the file does not cover the dir. - No grant lies at or below a floor or a
.gitdir, by its path or by its real path. So a rule never covers a write to the hooks or the config of a repository in its dir. - No
denyoraskrule of yours ([[permissions.rules]]) names a path at or below a grant of the call, andnetwork.denystill wins.
The order of the decision is: the floors, your deny and ask rules, the always
rules, the reviewer, you. The surface guard still checks the git dirs below a dir that
a rule opened, so a planted hook goes to the quarantine.
Only a terminal on your machine saves a rule. A phone gets forbidden for a, and
the question stays open. efr never writes a rule for the model, and the settings tool
cannot write sandbox.allow. Any program of yours that can write config.toml can
still add a rule: efrd reloads the file when it changes.
efrd checks each rule when it loads the file. A rule of a bad shape, such as
under = 5, stops efrd at start and makes a reload fail. The error names that rule
and its line, such as sandbox.allow[2] at line 12, also when the rules before it
are correct. A rule that fails a floor
check stays in the file but is disabled: efrd logs one warning, efr sandbox check
shows sandbox.allow[1] is disabled: <reason>, and the rule covers no call. Such a rule can
turn bad with no edit, for example when you register a new project below it.
efr config check checks only the shape.
efr sandbox allow lists the rules with their index, and disabled: <reason> for a
disabled one. efr sandbox allow remove N shows rule N in words, asks
remove sandbox.allow[N]: <rule>? y/n, and removes it only when entry N still holds
that rule. Then efrd reloads the file.
[[sandbox.allow]] covers shell commands in auto. [[permissions.rules]] with
write allow still covers write_file and edit; see
docs/permissions.md, "Your rules".
Background jobs
A background job is a command that runs on after its call and after its turn, such as
a dev server, a watcher or a slow build. The model starts one with shell and
background: true. The call answers at once with the job's id (j1, j2 and so on
in each conversation) and its output file. &, nohup and setsid in a normal
command still stop when that command ends; only a job runs on. You can also move the
agent's running shell call into a job with Ctrl+B (see "Moving a call to the
background").
What the model asks for
max_minutes: the time limit. The default isjobs.default_minutes(60). efr cuts a longer value tojobs.max_minutes(480), and the start result says so:time limit: 480 minutes (the most that efr allows).watch: a pattern (a regular expression of at most 200 characters) andmax_wakes. Each output line that matches tells the model, at mostmax_wakestimes, cut tojobs.max_wakes_per_watch(3).- A job takes no
timeout_secondsand nonested_shell, and a line that waits for input at the terminal cannot run as a job: its stdin is/dev/null. The same holds for a call that you move to the background: a line withsudo,sshor another program that reads the terminal cannot move.
Modes
A job can start in every mode.
- In
auto, the job's line goes through the exits like any line. A line with no exit starts at once in the sandbox. An exit goes to the reviewer or to you, as for a normal call, and the record tells the reviewer that the grants last for the whole job, up to its time limit. A covered delete in a job still goes to the reviewer, because undo of the start turn needs--force. - In
cautiousandmanual, efr asks you before each job starts. No reviewer decides it. An approved job runs in the exit child with your rights. - In full mode, a job starts with no question and runs in the exit child with your rights. It also refuses a delete that full mode guards, a delete that undo cannot revert, and every use of the bus, also a read-only query: with no sandbox, efr cannot hold the job to the bus filter. docs/permissions.md, "Full mode", has the rest.
- In every mode, efr refuses a job whose line asks for one of these kinds:
sudoand other ways to more rights, a read of a masked file, a change of a file that runs code later (also one that you name), a desktop command that types or starts programs, the whole D-Bus bus (a read-only query such assystemctl --failedruns with its own filtered bus for the whole job instead), and a socket of a container engine or a service manager (docker.sock,podman.sock,systemd/private). The model getsthis cannot run as a job; run that part in the foreground, and the rest as a job. Your yes does not change this: a job has no terminal for a password, and a process that such a grant starts would outlive the job. efr reads only the line: outsideauto, an approved job runs with your rights, so a program that it starts can still do these things. Only a setuid program cannot give it more rights. - The question of a job names its lifetime:
runs as a job for up to 60 minutes; these grants stay open until it ends. Only a terminal of yours answers it, never a phone. While you are away, it expires as other questions do, and no job starts.
How a job runs
- efrd starts the launcher itself (
efr-sbx run --job), not the hidden shell. The job has no terminal and its own session, and its stdin is/dev/null. It runs at nicejobs.nice(19) in the idle IO class, each file that it writes can grow to at most 4 GiB, and the kernel's out-of-memory killer takes it before your programs. A job in the exit child cannot gain rights through a setuid program (no_new_privs), also a job of full mode:sudogets no rights there. - The job gets the environment of the hidden shell, which the shell writes to a file
at each prompt where it changed, and starts in the hidden shell's directory. The
launcher sets
SCRATCHto the conversation's scratch dir in every job. A job in the exit child also gets the variables that place efr's own dirs (EFR_HOME,EFR_RUNTIME_DIR,EFR_DATA_DIR,EFR_CONFIG_DIRandEFR_STATE_DIR) that efrd has, so anefrin it reaches the efrd that runs it. A contained job never gets them. - A contained job writes where the sandbox of its turn writes. Its tool caches are
layers of its own on the disk, so a job never makes a foreground call wait, and efr
never applies the deletes of a job in your real caches. The private
/tmpis the conversation's. - In a contained job, every git dir of the plan and every git dir that efr finds in
the roots at the start is read-only. A job cannot commit; the model commits in the
foreground. The surface guard checks a running job every 5 minutes and before each
contained call whose roots meet the job's. A changed git setting or hook goes to
quarantine at once, and the job stops (
stopped: it changed git settings). - efrd reads the job's output from a pipe and writes
$SCRATCH/jobs/<id>.out: the first 1 MiB and the last 8 MiB, with[N bytes omitted]between them. Every contained call reads$SCRATCH/jobsbut cannot write it, so no command can change or replace a job's output. Snapshots leave the dir out. efr removes the output of a job 7 days after its end, and keeps at most 20 outputs of ended jobs in each conversation. Some programs keep their output in a buffer when it goes to a pipe: run them withstdbuf -oLor their own flag for line output. - At most
jobs.max_running(4) jobs run in a conversation, andjobs.max_running_daemon(16) in all. A start above a limit fails, and the model gets the list of running jobs.jobs.enabled = falserefuses every new job; running jobs run on.
Ports of a job
A dev server that the model starts as a background job is reachable from your
machine. When a contained job listens on a TCP port of its loopback, efr listens on
the same port of your 127.0.0.1 and sends each connection to the job's server. So
http://localhost:8765 in your browser opens the job's server on port 8765. Nobody is
asked: a forward only lets your programs connect to the job. The job gets no new right
on your machine and no new route out.
docs/adr/0016-job-port-forwarding.md records the
decision.
-
Which jobs. A job that runs in the sandbox in
auto, withnetwork.mode = "none"or the proxy. A job in the exit child (outsideauto, in full mode, or an approvedoutsidejob) and a job with a grant of the whole network already listen on your loopback, so they get no forward. A job with the network view cannot listen. A foreground call gets no forward: its servers stop when the call ends. A foreground call reaches the forwarded ports of the jobs of its conversation (see "Other calls", below). -
How efr finds a port. A small helper in the job,
efr-forward, reads the job's own TCP tables every half second. It has its own Landlock domain and seccomp filter: it cannot read files, bind or listen, and it connects only inside the job. A port counts when it listens for 1 second. A port in the job's range of ephemeral ports (32768 to 60999 by default) counts after 10 seconds, so a test server on port 0 that lives a short time gets no forward. efr reads a listen on127.0.0.1and the rest of127.0.0.0/8(also in the IPv6 form::ffff:127.0.0.1),0.0.0.0,::1and::. When the same port listens on IPv4 and IPv6, efr makes one forward and connects over IPv4. -
Ports that efr never forwards. A port below 1024. Port 3128 of a proxy job, where the bridge listens: a forward would give your programs the job's proxy grants. The ports of these services, where a client can send a password or a token first: 1080, 1433, 1455 (
efr login openai), 1521, 2181, 2375, 2376, 2379, 3128, 3306, 4222, 5432, 5672, 5984, 6379, 6443, 7474, 7687, 8086, 8118, 8200, 8384, 9042, 9050, 9092, 9150, 9200, 10250, 11211, 11434 and 27017. More than 8 ports of one job, and more than 32 ports of all jobs. efrd checks each port again, because it does not trust the helper in the job. -
A port in use. efr listens only on
127.0.0.1, never on0.0.0.0or::1, and only on the same port. When another program of your machine uses the port, efr does not forward it. A server of yours that listens on::1or::with that port counts as in use too: a browser tries::1first forlocalhost, so the URL would open your server, not the job's. A port that another job holds is in use as well. -
Who can connect. Only the programs of your own user. efrd reads the owner of each connection in the machine's
/proc/net/tcp, and closes a connection of another user at once. -
How long. The port stays the job's until the job ends, also while its server restarts, such as under a watcher. A connection in that time closes at once. The forwards end when the job ends, when efrd stops, and at a reload that sets
jobs.forward_ports = false. A latertruedoes not open them again for a running job. A restart of efrd keeps no forward. At most 64 connections of one job are open at one time. efr copies the bytes and reads none of them. -
UDP. efr forwards TCP only.
-
The model. The start result of a job with forwards tells the model that efr forwards its ports, so it gives you the URL. Then the model learns the outcome of each port. Before its next request, also in the same turn, efr adds a note that says that it comes from efr, not from you:
efr: job j1 listens on 127.0.0.1:8765; forwarded: the user can open http://localhost:8765, and your contained calls reach it at 127.0.0.1:8765.efr: job j1 listens on 127.0.0.1:8767; not forwarded: 127.0.0.1:8767 is in use on this machine. The user cannot open it in a browser, and your calls do not reach it; choose another port.The same form gives a port of another job, a port that efr never forwards and the limits.efr: job j1: port 8765 is no longer forwarded (jobs.forward_ports is off). The user cannot open it now, and your contained calls do not reach it.
The model reads each outcome once. A note does not start a turn: when no turn runs, it waits for your next prompt. The
jobstool shows the outcome of each port of a running job, such asports 8765 -> http://localhost:8765; 8767 not forwarded: 127.0.0.1:8767 is in use on this machine. -
Other calls. A later contained call of the same conversation reaches the job's server on the same port of its own
127.0.0.1and::1, socurl http://localhost:8765in a call checks the dev server that the model started. Nobody is asked, and noneeds.hostsentry is needed: efr knows the forwarded ports of the conversation, so a URL onlocalhost,127.0.0.1or[::1]with such a port is nohostexit. When a call names the port of a job that started to forward less than 3 seconds ago and does not forward that port yet, efr waits for the forward up to the rest of the 3 seconds before it decides the call. Each call has its own network namespace, so a small helper in the call,efr-reach, listens on those ports there and sends each connection to efrd. efrd finds the job by the call's conversation and the port on each connection, and opens the connection inside the job through its forwarder. efrd never opens a connection on your machine for a call.docs/adr/0017-busy-shell-and-call-reach.mdrecords the decision. The rules:- Only a contained foreground call in
autowith a network namespace of its own, withnetwork.mode = "none"or the proxy, whilejobs.forward_portsis on and a job of the conversation forwards a port. A job, a call in the exit child, and a call with the whole network or the network view get no reach. A call that you move to the background loses its reach at the move. A call in the exit child or with the whole network reaches the forward on your127.0.0.1anyway. - Only the ports that efr forwards for a running job of the call's own conversation.
A port of another conversation's job and a port that efr did not forward stay
closed in the call (
connection refused). - The helper listens on the ports of the start before the command runs, so in the call those ports are in use: a server of the call cannot bind them. A port that a job forwards while the call runs comes too, unless the call listens on it first; then it stays the call's.
- When the job's server stops or the job ends, a new connection to its port is reset at once, as on your machine. A call has at most 32 connections at one time, so the job keeps room for your browser.
- The helper has its own Landlock domain and seccomp filter: it reads no file, opens no TCP connection and runs no program. When it cannot start, the call runs with no reach, and nothing fails.
- A call can use a job's grants through a server of the job that relays requests,
such as a dev proxy. That is no new path: a call and a job of one conversation
share
$SCRATCH. The job's proxy budget still counts each byte that leaves. - A reload that sets
jobs.forward_ports = falseturns the reach off with the forwards, also for a call that runs at that time. - A port that a job of the conversation listens on, but that efr does not forward:
the port is in use on your machine or by another job, efr never forwards it, a
limit stops it, or a reload stopped it. A contained call does not reach the job
there, and the whole network would reach another program on your machine, not the
job. So in
auto, efr does not run a line that names such a port onlocalhost,127.0.0.1or[::1](also inneeds.hosts), and asks nobody. The model reads why, such asefr: port 8767 of job j1 is not forwarded: 127.0.0.1:8767 is in use on this machine. Your contained calls do not reach the job on this port, and full network would reach another program on this machine, not the job. efr did not run this call. Stop job j1 and start the server on another port.When the job started to forward less than 3 seconds ago, efr first waits for the outcome of the port, as for a forward. The text names only the jobs of the call's own conversation: a port that a job of another conversation holds isin use on this machine. A port of no job of the conversation, also a port of a job that ended, still asks for the whole network. Incautious,manualand full mode, each call runs outside the sandbox already, so this rule does not apply there.
- Only a contained foreground call in
-
The record. Each forward and each port that efr does not forward is one
job_port_forwardedevent of the conversation (see "What you see").
How a job ends
A job ends when its command ends, when the model stops it (job_stop), when you stop
it (efr jobs stop <id>, or Ctrl+X in the input row of a followed turn), at its time
limit, when the surface guard stops it, or when efrd stops. A job that a full call
started also stops when a config reload turns full mode off (stopped: full mode was turned off). Esc and the end of a turn never stop a job. The time limit counts the
time on the clock, so a suspend counts too.
A stop sends SIGTERM to the job's processes, then SIGKILL after a short wait. Then efrd
ends every process that still has the job's session (the sweep), and every process of
the job's cgroup (see "The cgroup of a job"). The end names the
processes that may still run, and a launcher that efr lost. When efrd stops, it stops
every job. When efrd dies, each launcher sees that and stops its job. At the next
start, efrd records each job that the last efrd left as stopped because efrd restarted, checks its git surface, and ends what is left of it. The terminal of the
job's conversation gets that end line at its next prompt.
The cgroup of a job
When efrd runs as its systemd unit with Delegate=yes (the unit of just install and
of the AUR packages from 0.0.5), each job runs in a cgroup of its own (ADR 0021). At its
start efrd moves itself into <unit>/daemon, and each job gets
<unit>/jobs/<conversation>-j<n>. efrd puts the launcher there before the job starts
anything, so every process of the job is in it. A call that you move with Ctrl+B moves
into its own cgroup at the move, with every process below its shell.
- The end of a job kills every process of its cgroup (
cgroup.kill), also a process that left the job's session withsetsid. The sweep of the session still runs too. efr jobs listshows the memory and the CPU time of each running job, such as(183.5 MB of memory, 42.1 s of CPU), and the model'sjobstool shows them too.- When efrd cannot give a job a cgroup, the job runs as before, held by its session
only, and
efr jobs listsays why, such as(no cgroup: efrd's cgroup is not delegated (Delegate=yes)): efrd runs by hand or in a test, the installed unit has noDelegate=yes, the machine has no cgroup v2, or efrd could not ask systemd. systemd's user manager marks no delegated unit, so efrd asks it once at start withsystemctl --user showof its unit (only asystemctlin a dir that only root writes, at most 2 seconds); a missingsystemctlor no answer means no cgroup. After you install the new unit, runsystemctl --user daemon-reloadand restart efrd. - A job in the exit child runs as you, and you own the delegated cgroups. Such a job can move its own processes out of its cgroup on purpose. The sweep of the session and the peer check of efrd's socket still apply to it.
Notices and wake turns
When a job ends, or a line of its output matches its watch pattern, efr tells the
model with a job notice. A notice is never your input: it holds the line efr: this is a job notice, not a message from the user; no human input has occurred., it never
answers a question, and it never counts as your permission. efr escapes the job's output in it and removes control and
format characters. A notice never enters a running turn. It waits until the turn ends.
- When the conversation is idle, the notice starts a short turn of its own, a wake
turn, marked
๐ค (job notice, not your input). This occurs in every mode. The wake turn runs in the mode of your newest prompt, or in the mode of the job's turn when you sent no prompt since. Outsideauto, its commands ask you as in any turn of that mode. - At most
jobs.max_wakes_per_hour(6) wake turns start in a conversation in an hour, andjobs.max_wakes_per_hour_daemon(12) in all. efr counts them over restarts. 0 means that notices never start a turn. - A wake turn runs at most 20 tool calls. A job that a wake turn starts never starts
another turn: its notices wait for your next prompt (
(job of a wake turn)). - One turn takes at most 10 waiting notices and 32 KiB of their text.
- A question in a wake turn waits only
presence.away_wait_secs, unless a terminal of yours follows the conversation. Its summary starts with(job notice turn). The reviewer's refusals count on from one wake turn to the next, until a turn of yours. - A notice of a job that ended at a restart of efrd never starts a turn. It rides in your next prompt.
- A job that you stop also starts a wake turn. Only two notices never start a turn. The notice of a job that the model stopped, because the model read the output when it stopped the job. And the notice of a job that efrd stopped because full mode went off, because a wake turn would run in the mode that you just turned off. Such a notice rides in your next prompt.
What you see
- While efr follows a turn, the view shows
job j3 started; output $SCRATCH/jobs/j3.out; ends in 60 minutesunder the call, which shows(background). It shows each end of a job of the conversation, such asjob j3 stopped by you after 92 minutes; 1.2 MB output, a matched line (job j3 matched: <line>) and a notice that did not start a turn (job j3: no wake turn (wake limit reached)). - When efr forwards a port of a job, the view shows
job j1 listens on 127.0.0.1:8765 -> http://localhost:8765, with the URL as a link where your terminal shows links (job j1 listens on [::1]:5173 -> http://localhost:5173for IPv6). A port that efr does not forward shows why:job j1 listens on 127.0.0.1:8765; not forwarded: 127.0.0.1:8765 is in use on this machine(orin use by job j2),...; not forwarded: efr does not forward port 6379 (a service port),...; not forwarded: the job forwards 8 ports alreadyandefrd forwards 32 ports already. A reload that turns forwards off showsjob j1: port 8765 is no longer forwarded (jobs.forward_ports is off). A terminal that does not follow the conversation shows the line at its next prompt:efr: <title>: job j1 listens on 127.0.0.1:8765 -> http://localhost:8765.efr historyshows it too. - Between turns, your next prompt shows
efr: job j3 ended (exit 0): <title>. After a wake turn, it shows the jobs and the first line of the model's answer:efr: job notice turn finished (job j3): <title>: The tests pass.; efr history shows all. - A notice file that is older than your shell does not show: it belongs to an earlier terminal with the same name, such as one before a reboot. The rule works per file, so one new line in it also shows the older lines of that file.
efr jobslists the jobs of this terminal's conversation;--alllists them all. A running job with forwards shows its ports, such as(ports 8765, 5173).efr jobs stop j3stops one and shows its last lines.efr historyshows each start and end, and marks a wake turn;--verboseadds the last lines of the output and the text of each notice.
Moving a call to the background
When a shell call of the agent takes long, such as a slow build or a test run, you can
move it into a background job with Ctrl+B in the turn view. The turn goes on at once,
and the command runs on as a job. A job notice tells the model when it ends.
docs/adr/0018-moving-a-call-to-the-background.md
records the decision.
- The key. Ctrl+B acts while a
shellcall of the turn that you follow runs, whatever the input row holds: the row keeps its text. In the input row, Ctrl+B does not move the cursor back; the Left arrow does. While the row is empty and the call has run for 2 seconds, the hint of the row starts withctrl+b background. In tmux, press Ctrl+B twice, because tmux takes the first one. The key acts only on a call that does not start a job and does not wait for your answer to a question. It does not act on a call of a turn that your prompt waits behind, such as a wake turn: the view may not have seen that call start. With no such call, a note saysno running shell call or command left in the hidden shell to move. A phone cannot move a call. - A command left running. Ctrl+B also moves a command that a call left running
in the hidden shell at its timeout (see "A busy hidden shell"): in the turn view while
no call runs, and in
efr shell(,shell), whose keys line then saysctrl+b moves it to a background job. It moves only while it waits for no input, with the same question and the same checks as a running call. Its call answered the model long ago, so the model reads nothing at the move; the hidden shell is free at once, and the job's notice tells the model at its end. The command's file changes after the move do not join the turn of its call, as for any command left running. - The question. A call in the sandbox moves at once, with no question. It keeps
its sandbox, so the move allows nothing new, and your key press is the answer, also
in
cautiousandmanual. A call outside the sandbox (an approved exit, a call incautiousormanual, or full mode) asks first:? move this command to the background, the command,it keeps running with your full rights after the turn, for up to 60 minutes, and one rowas a job: ...for each exit of the job decision.ymoves it, andnor Esc leaves it in the turn. When the command ends or starts to wait for input while the question shows, nothing moves. The question is good for 60 seconds; after that, efr saysthe question expired; press ctrl+b again. - The checks. efr decides the line again as a job, with the mode and the settings
of the moment of the key press. It refuses the move in these cases, and the view
shows
the command was not moved: <reason>:- jobs are off (
jobs.enabled = false), or the most jobs that efr allows run now; - the line has
sudo,sshor another program that reads the terminal (the command can ask at the terminal; a job has no terminal); - the line types into a nested shell, starts a shell or a REPL, or a full-screen program holds the screen;
- the command waits for input now (
the command waits for input; answer it first), has not started, or ended; - efr refuses the line as a job (
this cannot run as a job), as for a job that the model starts (see "Modes"); - a call in the sandbox holds the sandbox's cache: with
sandbox.cache_mode = "overlay", orsandbox.keep_downloads = true, and a cache dir that exists (the call holds the sandbox's cache; it cannot move); - the job decision changed between the question and your
y(the decision changed; press ctrl+b again).
- jobs are off (
- What the model reads. The call answers at once with the output so far and a
note:
the user moved this command to the background after 42s: job j3, the output file, the time limit, that a notice comes when it ends, and that the hidden shell is new. For a call in the sandbox, the note also says that efr does not forward its ports and that it no longer reaches the forwarded ports of other jobs. - How the job runs. The command keeps its terminal, its processes, its sandbox
and its grants. The job owns the call's old hidden shell until the command ends;
then efr closes that shell. The next call of the conversation gets a new hidden
shell in the same directory. The new shell has none of the variables, functions or
aliases of the old one. The sandbox state of the conversation stays.
- The time limit is
jobs.default_minutes(60), cut tojobs.max_minutes, from the move. The time before the move does not count. - A call in the sandbox keeps the network proxy and the bus filter of its call. It
gets no port forwards, and it no longer reaches the forwarded ports of other jobs.
To open a server in your browser, the model starts it with
background: true. - A call in the sandbox keeps its writable git dirs. Only the surface guard at its end checks them, not every 5 minutes as for a contained job of its own.
- The end of a moved call does not write the sandbox state back: the exports, functions and aliases that the moved command makes do not reach later calls.
- The output file starts with the output so far. The rest comes as plain text, with
no escape sequences. When the job cannot read the output fast enough, efr drops
a part and writes
[efr: N bytes of output dropped]in its place. - Nobody follows a job. When the moved command asks for a password, efr stops it at
once, and the notice says so. A plain question waits, and nobody can answer it:
stop the job with
efr jobs stop. While it waits,efr jobsshows(waits for input; efr jobs stop ends it), and the notice of its stop says that it waited for input.
- The time limit is
- How it ends. As every job: when its command ends, when you or the model stop
it, at its time limit, or when efrd stops. A stop sends SIGINT to the command, and
efr closes its shell when the command did not end after 6 seconds. At the end, efr
ends every process below the old shell, also a process that left its session with
setsid, and then closes the shell. After a crash of efrd, its next start records a moved job asstopped because efrd restarted. - What you see. The view shows
(moved to job j3)in place of a result, andmoved to job j3 by you after 42 seconds; output $SCRATCH/jobs/j3.out; ends in 60 minutes.efr jobsmarks the job(moved by you).efr historyshows the move, with one lineyour move covered: ...for each exit that yourycovered.
Undo and jobs
efr undo refuses while a job of the conversation runs, or a job of another
conversation can write in a root of the undo, and names it. A job outside the sandbox
runs as you and can write everywhere, so it blocks every undo. The refusal says:
job j3 runs; stop it first (efr jobs stop j3). When a job ends, its file changes join
the turn that started it, and the undo of that turn needs --force. This is also true
for a call that you moved to the background: its changes after the move join the turn
of the call. A file that a later
call also changed stays with that call's turn. A job's copies of files outside the
project have no "after" copy, so undo lists them as not covered (a job wrote it), and
--force brings back the state before the job.
A job's end starts a wake turn. When that wake turn changed nothing, efr undo with no
--turn passes over it and takes back the job's turn, and the wake turn does not count
as a newer turn that blocks the undo.
Undo
efr undo takes back the newest turn of this terminal's conversation that is not
undone. It shows a preview and asks undo? y = yes, n = no:
$ efr undo
undo turn 0193...: change a.txt and add notes.md
restore a.txt
trash notes.md
skip b.txt (changed after the turn; --force overwrites it)
not covered .env.large (the snapshot left it out)
ref main 1a2b3c4 -> 5d6e7f8
your git index is unchanged; git status can show staged changes
undo? y = yes, n = no
undone: 1 file restored, 1 file moved to ~/.local/share/efr/undo-trash/u-0193.../, 1 ref moved
- Undo acts only on the paths that the turn's own calls changed. A change that you made between two calls stays. A change that another conversation or you made while a call ran counts as the call's change, so its old bytes go to the undo trash.
- A path that changed after the turn stays, and the preview says
skip.--forceoverwrites it. When only--forceacts on any row of the preview,efr undowithout it changes nothing, does not ask, and fails with a hint to use--force. A path that a snapshot left out, such as a large ignored file, isnot covered: undo never writes, deletes or trashes it. - Every file that undo overwrites or removes, also outside the project, goes to
~/.local/share/efr/undo-trash/<undo-id>/first. A file that undo makes again gets its mode from the snapshot, without set-id bits; a.envwith mode 0600 stays 0600. A directory that undo makes again gets mode 0700, because no snapshot records the mode of a directory. - Undo restores the branches, tags,
refs/stashandHEADthat the turn moved, from the mirror that efr took at the turn's first snapshot, by compare and swap. Your git index stays as it is. A turn that only moved refs, such asgit reset --soft, ends withmoved 1 ref in <project>; efr undo reverts this turn. When efr cannot keep the refs that an undo moves away from, the undo leaves those refs as they are, so a redo can always give them back. - Undo is a stack: a turn cannot be undone while a newer turn of the conversation is
not undone, unless
--force.--redotakes back the newest undo.--listshows the turns and which are undone or stopped.--turnpicks one,--dry-runonly shows the preview, and--yesneeds no question. Without a terminal,efr undoneeds--yes. - Undo refuses while a turn, a compaction or another undo of the conversation runs, and while a command of the conversation still runs after its timeout. A prompt that you send during an undo waits for it.
- An undo that stopped half way, such as after a stop of efrd, goes on at the next
efr undo. Undo holds the plan lock of each project that it writes. An undo that stopped at a write that failed saysundo stopped: ..., andefr historyshows it. An undo that could write nothing, such as when each path changed again, leaves the turn as it is. - A call that you interrupt, or a command that runs on after its timeout, can change
files after efr's last snapshot of the call. The turn then says
not covered: <project> (a call did not finish), andefr undo --forcetakes back those files too. A home copy whose call did not finish shows asnot covered: <path> (the call did not finish). - Undo works for a turn of every mode with a snapshot. The hint
efr undo reverts this turnshows only after anautoturn. Inmanualandcautious, a line runs in your own shell, and writes outside the project are not covered; the preview of such a turn says so. - A phone cannot undo. The model has no undo tool, and
undo.*andsnapshot.*are settings that only you change.
Routine destructive lines. In auto, with sandbox.ask_destructive = "unless_undoable" (the default) and undo.enabled = true, a destructive part of a
line runs with no review and no question when efr's snapshot holds every file that it
can change. efr checks this before the decision and again under the plan lock, after
the call's own snapshot. When the check fails then, the call does not run, and the
model runs the line again.
| Form | Covered when |
|---|---|
rm -r <p>, dd of=<p>, truncate <p>, : > <p>, find <p> -delete, an apply_patch delete or move | <p> lies below a project or $SCRATCH that has a snapshot of this turn, not in a git dir or a floor, and each file at or below it is in the snapshot |
git reset --hard, git checkout that discards, git restore, git switch --discard-changes | every tracked file is in the snapshot, the index equals HEAD, and for reset and switch the mirror holds the refs |
git branch -D | the mirror holds the refs |
git stash drop, git stash clear | the mirror holds refs/stash, and the stash has one entry |
git clean -f without -x and -X | every untracked file is in the snapshot |
shred, git clean -x and -X, git reflog expire, git gc --prune | never |
Only the destructive exit of that part goes; every other exit of the line keeps its
route. In manual and cautious, efr still asks you for each delete.
sandbox.ask_destructive = "always" keeps the review. undo.enabled = false or
snapshot.enabled = false turns undo and the routine destructive lines off. With
snapshot.enabled = false, a named floor file edit asks you again, because efr cannot
copy it first.
When the sandbox cannot run
At start, efrd runs a probe: one real launch with a self-test inside. It runs the probe
again after a reload that changes [sandbox], after a launch fails, and before an
auto turn when the last probe failed. So a fix needs no restart.
auto needs every part. When one part is missing, an auto turn runs as cautious.
There is no partial sandbox. A contained call that fails to start returns an error to
the model; efr never runs it outside the sandbox.
Full mode (docs/permissions.md, "Full mode") uses no sandbox, so the probe and
sandbox.enabled have no effect on it: a full turn runs as full mode also where
auto cannot run, and it never falls back to another mode for the sandbox. It still
reads each line for the same exits, with no egress and no bus filter, and records
them. Where auto can run, auto is the safer choice.
What you see:
,mode autosays at once:efr: auto needs the sandbox; turns run as cautious: Landlock ABI 6 found; auto needs 9 (Linux 7.1). efr sandbox check shows more.- Each turn that asked for
autostarts with a dim line:auto is not available here; this turn runs as cautious: <reason>. efr statusshows thesandboxline always:ready (...)orunavailable: <reason>; auto runs as cautious, with the fix.
After the self-test passed, the probe runs a second launch with the network view and
its own checks. Each check passes only on its exact error code. tcp4 runs first: when
a TCP socket does not fail with EAFNOSUPPORT, the profile is not in place, and the
self-test stops before it opens any other socket. Then tcp6, udp4, udp6,
packet and other families (EAFNOSUPPORT), the ROUTE and SOCK_DIAG netlink
sockets (they must open), other netlink protocols (EPROTONOSUPPORT), listen on an
unnamed Unix socket and pidfd_getfd (EPERM), a connect and a send to the probe's
abstract sockets outside the sandbox (EPERM from Landlock), and a socket path
(EACCES). A failure here is a warning, never a failure of the sandbox: auto still
runs, the network view is off, and ss and ip go to the reviewer. The warning says
the network view's self-test failed, so ss and ip need a review: <check>. efrd
builds its engine again after each probe, so the view follows the newest result.
A third launch checks the proxy's bridge with a test socket of the probe, which
answers like the proxy. proxy_tcp: a TCP connect to 127.0.0.1:3128, a CONNECT probe.test:443, the answer and one echoed byte. proxy_socket_direct: a connect to
the bound socket gives EACCES. proxy_tcp_other: TCP to 192.0.2.1:9 gives
ENETUNREACH. proxy_bridge_signal: a signal to the bridge gives EPERM. A failure
here is a warning too: with network.mode = "proxy", efrd falls back to no network,
logs one warning, and the first contained command after each start of efrd gets the
note efr's network proxy is off: its self-test failed; run efr status. efr status
then shows network none (proxy self-test failed).
While dbus.proxy is true, efrd also checks the bus filter at start and after each
probe: xdg-dbus-proxy --version must say 0.1.9 or newer, and one filter with the
built-in rules must start under the wrapper, write its ready byte and end cleanly. A
failure here is a warning too: the filter is off, and the commands of "The bus filter"
become bus exits, which is stricter, never looser: the system bus queries go to the
reviewer, and the session bus commands ask you. efr status shows bus filter on, or
bus filter off with the reason. While a check runs after a reload, efr status keeps
the state of the last check; it shows off only when the new check fails. A fresh
efrd that never checked the filter shows off with no reason until its first check
ends.
These warnings show in efr sandbox check and in efrd's log; auto still runs:
| Warning | Meaning | Fix |
|---|---|---|
efr's bus filter is off: xdg-dbus-proxy is not installed; ... | you set dbus.proxy = true in config.toml, but efrd found no xdg-dbus-proxy that root owns. With the default value, efr shows no warning, and efr status shows bus filter off (xdg-dbus-proxy is not installed) | Arch: pacman -S xdg-dbus-proxy. Other systems: a package of version 0.1.9 or later |
efr's bus filter is off: the installed xdg-dbus-proxy is older than 0.1.9, the first version without known filter bypasses; ... | you set dbus.proxy = true, but the program is older than 0.1.9. Older versions cannot serve hostnamectl and the other systemd tools, and let some messages past the filter. Ubuntu 24.04 and Debian ship 0.1.5. With the default value, efr shows no warning, and efr status shows bus filter off (xdg-dbus-proxy is too old) | install 0.1.9 or later, or set dbus.proxy = false |
efr's bus filter is off: its self-test failed; ... | the filter did not start or did not end cleanly | run efr sandbox check; efrd's log has the error |
sandbox.allow[1] is disabled: <reason> | an always rule crosses a floor (see "Always-allow rules") | change or remove the rule: efr sandbox allow remove 1 |
| Failure | Reason | Fix |
|---|---|---|
| no bubblewrap | bubblewrap is not installed | Arch: pacman -S bubblewrap; Debian and Ubuntu: apt install bubblewrap; Fedora: dnf install bubblewrap |
| old bubblewrap | bubblewrap 0.6.2 found; it lacks --bind-fd | update the package: efr needs bubblewrap 0.10.0 or newer, or a package with --bind-fd, such as Ubuntu 24.04's 0.9.0-1ubuntu0.3 |
| setuid bubblewrap | bubblewrap is setuid; efr needs the unprivileged build | install the build that is not setuid |
| user namespaces off | unprivileged user namespaces are off (kernel.unprivileged_userns_clone = 0) | sysctl kernel.unprivileged_userns_clone=1 (Debian kernels) |
| AppArmor | AppArmor blocks user namespaces for bwrap (kernel.apparmor_restrict_unprivileged_userns = 1) | an AppArmor profile for bwrap with userns,, or set the sysctl to 0 |
| Landlock off | Landlock is not enabled | add landlock to the lsm= boot parameter |
| Landlock too old | Landlock ABI N found; auto needs 9 (Linux 7.1) | a newer kernel |
| kernel without the fix | the kernel lacks the Landlock fix for disconnected directories | a newer kernel |
| launcher in a project | efr-sbx lies in a writable project (target/debug) | install efr, or run efrd from outside the project |
| no launcher | efr-sbx is not installed next to efrd | install efr-sbx next to efrd or in ../lib/efr/ (just install puts it in ~/.local/lib/efr/) |
| launcher changed | the launcher copy does not match the installed efr-sbx | restart efrd, so it copies the launcher again |
| probe without a report | the sandbox launcher's probe failed: ... | run efr sandbox check; reinstall efr when it fails again |
project at ~ | auto cannot use your home directory as a project (per turn) | register a narrower project, such as efr project add ~/dotfiles |
a dir below /tmp | efr's state dir /tmp/... lies below /tmp or /var/tmp, which the sandbox replaces with its own (also for XDG_RUNTIME_DIR) | keep efr's state dir (EFR_STATE_DIR or EFR_HOME) and XDG_RUNTIME_DIR outside /tmp and /var/tmp |
| self-test | the sandbox let a test through: ... | file an issue; efr sandbox check has the details |
| turned off | sandbox.enabled = false | set sandbox.enabled = true |
What to expect on some systems (the efr project has tested only the first row):
| System | Kernel and Landlock | User namespaces | Result |
|---|---|---|---|
| Arch Linux | 7.2, ABI 10 | allowed; bubblewrap 0.13.0, not setuid | ready |
| Ubuntu 24.04 LTS | 6.8, ABI 4 | AppArmor restricts them by default | unavailable: the Landlock ABI |
| Ubuntu 26.04 LTS | not known yet | as 24.04 | unavailable unless the kernel is 7.1 or newer |
| Debian 13 | 6.12, ABI 6 | allowed | unavailable: the Landlock ABI |
| Fedora | needs 7.1 or newer and landlock in the LSM list | allowed by default | ready only on 7.1 or newer |
| WSL1 | no Landlock | - | unavailable |
Commands
efr sandbox check runs the probe now and prints each check:
$ efr sandbox check
ok sandbox.enabled = true
ok launcher copy /run/user/1000/efr/bin/efr-sbx (from /home/u/.local/lib/efr/efr-sbx, sha256 ok)
ok platform linux x86_64
ok Landlock ABI 10, errata 0xf
ok bubblewrap 0.13.0 at /usr/bin/bwrap, not setuid
ok launcher /run/user/1000/efr/bin/efr-sbx
ok zsh /usr/bin/zsh
ok every PATH entry is absolute
ok user namespaces: the probe sandbox starts
ok self-test: 16 checks passed
warn ~/dotfiles/bin is on PATH and inside the registered project ~/dotfiles; it stays read-only in the sandbox
warn the registered project ~ is your home directory; auto runs its turns as cautious
launch 3.6 ms
netview yes
network proxy
auto: ready
A failed check has a fix: line below it. The command exits with 1 when the sandbox
is not available. netview no (the warning above says why) means that the self-test
of the network view failed; auto still works. netview off (network.netview = false)
means that you turned the view off. network proxy means that contained commands
reach the network through efr's proxy; network none that they have no network
(network.mode = "none"), and network none (proxy self-test failed) that the proxy
is off because its self-test failed. bus filter on means that read-only system
queries run through the bus filter; bus filter off names the reason, such as
dbus.proxy = false or a failed self-test.
efr sandbox allow lists the always rules of config.toml with their index, and
disabled: <reason> for a rule that crosses a floor. efr sandbox allow remove N
shows rule N, asks remove sandbox.allow[N]: <rule>? y/n, removes it and reloads the
config (see "Always-allow rules").
efr sandbox explain PATH tells what a contained command can do with one path, from
the current directory:
$ efr sandbox explain ~/.zshrc
~/.zshrc, from ~/p/app in auto:
read yes
write no: a shell startup file (floor); a write goes to the reviewer when a message of yours names the file, and efr copies the file first, so efr undo reverts it; else you decide; a write is a persistence exit
, user only follows the exit for a kind that only you approve, such as a synced
write.
efr paths shows the launcher ($XDG_RUNTIME_DIR/efr/bin/efr-sbx, copied from the
installed efr-sbx and checked by SHA-256), bubblewrap, and the sandbox's state and
runtime directories. docs/storage.md lists the files. efrd finds the
installed launcher next to its own program, else in ../lib/efr/ (so
~/.local/lib/efr/efr-sbx for ~/.local/bin/efrd); it is never on PATH.
efrd runs the probe at start, after a change of [sandbox] or of the projects, after
a call whose sandbox could not start, and before an auto prompt while the last probe
failed. So a fix such as a new package or a sysctl needs no restart. When the result
changes to unavailable, the event log records it once (sandbox_unavailable).
A process that an approved command leaves behind, such as a double-forked one, can
still reach efrd's socket, because it runs outside the sandbox. efrd gives a process
that descends from a hidden shell, or that shares a hidden shell's session, only the
read scope: it cannot send a prompt, approve, answer input, change the config or
register a project.
Where the time of a call goes
Each step of a call writes a debug line to the log of efrd. The line has the name of
the step (phase) and its time in milliseconds (elapsed_ms), in its text and as
fields. journald keeps the fields apart from the text, as F_PHASE and
F_ELAPSED_MS, so the text must hold them too. To see the lines of one prompt:
- Set the log filter:
efr config set log 'info,efr_=debug'. efrd applies it at once, with no restart. - Send the prompt.
- Read the lines:
journalctl --user -u efrd --since -5min | grep phase=. Underjust run, efrd writes them to stderr. - Set the filter back:
efr config unset log.
The steps, in the order of a call:
| Phase | What takes the time |
|---|---|
model_open, model_first_event, model_first_tool_call, model_answer | the model: from the request to the open stream, to the first event, to the first tool call and to the end of the answer; each request has these lines. The model_answer line also names the stop_reason of an answer that ended, such as end_turn, tool_use, max_tokens or refusal, or outcome=interrupted or outcome=error for a call that did not complete and so has no stop reason |
provider_connect, provider_accepted, provider_first_event | the transport of the request: a new WebSocket's handshake, then the time to the server's first event and to the first text, reasoning or tool call. The lines name the transport (transport=http or websocket), the connection (new, reused, or pool for HTTP) and the input (full or incremental), so you can compare the two transports |
requirements | what the call needs: real_paths (the links of its paths) and, in auto, facts (the programs, the files and git of the line) |
engine, exit_prediction | the permission engine, and the narrowest launch for the exits of the line |
record_started | the event log records the start of the call |
approval_request, approval_wait | the question, and the time until you answer it; only when efr asks |
tool_run | the whole run of the tool, with the steps below |
shell_free_wait | efr waits until the hidden shell is free |
sandbox_prepare | the call's dir: plan_lock, plan_build and call_dir_write |
surface_before | the record of the git settings and the files that run code, before the call |
sandboxed_run | the run in the hidden shell, from the typed line to the end |
shell_typing | zsh reads the typed line and starts it |
shell_command | the wrapper in the hidden shell: wrapper_snapshot (the snapshot of your functions, aliases and options, when it is old), wrapper_launcher and wrapper_apply (the cd and the exports that come back) |
launcher_* | the steps of the launcher: prepare_dirs, plan, state_read, guard_before, layer_lock, launch_open, bwrap_setup, layers (the cache overlays), child (the command), teardown, records_and_state and guard_after |
shell_check, shell_total | efr reads the facts of the end of the run; the run from the typed line to its end |
call_dir_remove | efr removes the call's dir |
record_completed | the event log records the result |
tool_call | the whole call, from the model's tool call to its result |
Then the next request to the model starts, with its own model_* lines.
The bench shell_routine_calls_in_auto_cost_close_to_cautious of efrd's tests runs
ten true calls in auto and in cautious with a model that answers at once. It
prints the time of each call and the time of each step per call. Run it in a release
build:
cargo build --release -p efr-sbx
EFR_TEST_SBX_BIN=$PWD/target/release/efr-sbx EFR_TEST_ZSH=1 cargo nextest run --release \
-p efr-daemon --success-output immediate -E 'test(shell_routine_calls_in_auto)'
In a release build, it fails when a call in auto costs 40 ms more than a call in
cautious, or when a call costs more than 250 ms. The turns in auto from the
project, in auto from the home directory and in cautious take turns, so all see
the same load. A debug build compares the fastest call of each
mode, because load delays some calls but never makes one faster: it fails when the
fastest call in auto, from the project or from the home directory, costs 75 ms more
than the fastest call in cautious.
The bench shell_call_phases_in_a_project shows where the time goes in a project of
your choice. It runs 50 true calls in auto and prints the mean and the 95th
percentile of each step. EFR_BENCH_PROJECT names the project, as an absolute path;
without it, the bench makes a small git repository. It checks nothing, so it runs only
when you ask for it:
cargo build --release -p efr-sbx
EFR_TEST_SBX_BIN=$PWD/target/release/efr-sbx EFR_TEST_ZSH=1 EFR_BENCH_PROJECT=<dir> \
cargo nextest run --release -p efr-daemon --run-ignored only \
--success-output immediate -E 'test(shell_call_phases)'
Give it a copy of a project. The surface guard moves a git setting that changes while
a call runs to quarantine, also when another program changes it. The bench keeps its
runtime dir on the disk, not on tmpfs, so call_dir_write costs more there than in a
real efrd.
Settings
The [sandbox] table of config.toml holds every key; docs/config.md
lists them with their defaults. Each key applies from the next call or turn. A change
of efr's settings through the settings tool still asks, in every mode.
[classifier] controls the reviewer: enabled (false asks you about every exit),
model, fallback_to_turn_model, effort and timeout_secs. [presence] controls
when efr counts you away: away_after_minutes, away_wait_secs and push (no phone
client exists yet, so it does nothing today). Each key applies from the next
question, and none needs a restart.
[network] holds netview (default true): false makes ss, ip read forms,
nstat and reads of /proc/net host_view exits for the reviewer again. It also
holds the keys of the proxy: mode, allow, deny, [[network.hosts]] and
max_upload_bytes (see "The network proxy"). Each applies from the next call, with no
restart; the allow and deny lists also reach the calls that run. The settings tool
marks each change that opens more as "loosens permissions": netview from false to
true, mode to "proxy", a new allow entry, a removed deny entry, a new or wider
[[network.hosts]] entry, and a higher max_upload_bytes. Only an editor changes
[[network.hosts]].
[dbus] holds proxy (default true), which turns the bus filter on, and
[[dbus.allow]], more bus methods for the filter (see "The bus filter").
[[sandbox.allow]] holds the always rules (see "Always-allow rules"). Each applies from
the next call, with no restart; a running job keeps the bus rules of its start. The
settings tool marks dbus.proxy from false to true as "loosens permissions", and
it cannot write dbus.allow or sandbox.allow: only an editor, the a answer and
efr sandbox allow remove change those.
[jobs] holds forward_ports (default true), which forwards the ports of contained
jobs (see "Ports of a job"). A job reads it when it starts. false at a reload also
stops the forwards of the jobs that run, and a later true does not open them again.
The change from false to true loosens permissions: the settings tool marks it, and
the config hold of full mode holds it.
Known limits
| Limit | What can happen | What efr does |
|---|---|---|
| a bug in the kernel, bubblewrap or overlayfs | an escape | three layers; bubblewrap from your distribution; the probe at every start |
code that the model writes and you run later (a Makefile, a build.rs, a new dependency, a project .cargo/config.toml) | that code runs with your rights | the turn-end list of such files; efr sandbox check warnings |
| a hard link that existed before, from a secret or an rc file into a write root | a write in the project changes the file outside | efr sandbox check warns about such files |
| command lines of other processes | the sandbox reads them, as ps does in cautious | none; do not put secrets in command lines |
| a fork bomb or a full disk | the machine slows down or fills up | none yet |
| a line that you approved in another mode changes the hidden shell | that line has your rights | auto calls never run model text in the hidden shell itself; the wrapper check catches a changed wrapper |
the private /tmp | wrong paths for you or the model | the prompt text says so; share files through $SCRATCH |
| a large rc file | slower calls | efr compiles large snapshots of your shell |
| the surface guard checks known places only | a deep nested repository with hooks shows only at the end of the turn | the turn-end check and list |
a bus grant, or a host grant of the whole network, opens more than the line needs | the approved call can use the whole bus or the whole network | the question says so; only you can approve the whole session bus; with the proxy, a host that the line shows gets a grant of that one host; a read-only system query and notify-send get the bus filter instead |
| the bus filter checks no arguments | Properties.Get on an allowed path reads every property there, also the environment of a transient user unit | exact paths; no property of the user manager itself |
| the bus filter runs outside the sandbox with your rights on the bus | a memory bug in xdg-dbus-proxy reaches your full bus rights, on the session bus also StartTransientUnit | the distribution's program, owned by root; its own Landlock domain, seccomp filter and fd limit; one filter per call that ends with it |
without xdg-dbus-proxy, or with dbus.proxy = false, each command that needs the session bus asks you | more questions, also for notify-send and systemctl --user --failed | install xdg-dbus-proxy 0.1.9 or later, so these commands run through the filter |
| a call with the filtered session bus can send any notification | a notice can look like one from another program | the filter lets through only the calls of notify-send; it cannot read or close a notice |
a script that runs systemctl --user itself | it fails, because systemd looks only for its private socket | run systemctl --user on the line, where the sandbox's shell gives it the session bus |
| a running job keeps the bus rules of its start | a removed [[dbus.allow]] entry or dbus.proxy = false does not reach it | efr jobs stop ends it |
| an always rule stays open for every later call | a message or a file that steers the model can use a host or a write dir that a rule opened | narrow kinds; the floors; the confirm step; efr sandbox allow remove N |
a one-time "yes" for a write to a dir above a floor that does not exist yet, such as ~/.config with no ~/.config/autostart | the call can make that floor | always rules never cover such a dir; the reviewer sees the dir |
tty and $$ show the sandbox's view | confusing output for system work | the prompt text says so; df, findmnt, ss and ip show your machine through the views |
| a call with the network view runs in your network namespace and can run any code | a gap in the seccomp profile would let it send from your machine | only Unix and netlink sockets, and only two netlink protocols; listen and pidfd_getfd refused; Landlock refuses TCP; the probe checks each refusal at every start; the view only beside write and unmask grants |
a host program can send a datagram to an abstract socket name that a network view call binds, also with a socket in it (SCM_RIGHTS) | the call could receive a connected socket from a host program and send through it | it needs a host program that sends to that name on purpose; listen fails, so no stream service; a job ends at its time limit |
| a call with the network view sees your sockets, peers and routes | the model can learn the sites that you visit | the purpose of the view; before, the same output came after a review |
a running job keeps the network view after network.netview = false or a failed probe | the job reads your network until it ends | its filter is in place and the kernel does not change; efr jobs stop ends it |
every contained call can list every mount of the machine through /proc/<pid>/mountinfo | the call learns your mounts, also those of other users and containers | none: a new procfs or hidepid would hide ps too; the mount view adds sizes only for your own mounts outside masks |
| the mount view shows how full your hidden tmpfs mounts are | the call learns numbers, not names or contents | no sizes below the masks and secrets of the call |
the df and findmnt of the mount view work only on the line | a script or sh -c 'df' shows the sandbox's mounts | that output is safe; it shows the sandbox; a path or a wrapper, such as watch df, is an outside exit |
| a long job keeps the mount snapshot of its start | old sizes in a job's output | none |
| a hung NFS or FUSE mount, an automount | df could hang or mount a share | the helper ends after one second, the mount shows - for a minute; no statvfs for autofs and the other dummy types |
| an approved exit outside the sandbox has your full rights | it can read secrets, use the network, or start a service through systemd-run --user that the call end cannot stop | one command per exit; the question shows the whole line, each program and the untrusted mark; privilege, upload and persistence are yours only |
| a process that runs as you, outside the sandbox, can change the git code after efrd's last check | git runs that code with your rights | efrd checks right before the exit child starts, and runs the guard of every job first, so the window is small |
a pinned hook that runs project code, such as .husky/pre-push that runs npx lint-staged | that project code runs with your rights, also when a sandboxed command wrote it | efr checks the hook file and the program of its #! line, not what the hook calls; the turn-end list names changed files that run code later |
| the hidden shell writes its environment at a prompt | before its first prompt, efrd checks the git code with its own start environment, not with the git variables that your startup files set | a sandboxed command cannot change HOME, XDG_* or GIT_* of the hidden shell |
| an outside background job that runs git | efrd checks its git code for the question, but not again when the job starts | the question names each writable part that efrd found |
| gh's config dir comes from the hidden shell's environment, which it writes at a prompt | the first sandboxed command after you log in, before any hidden shell had a prompt, cannot reach the hosts of gh's credential helper, such as github.com | efrd takes the dir of the newest hidden shell, since they all run your startup files; with none it denies those hosts, and the note says why; the next command works |
Git Credential Manager can keep its plain store in another dir (GCM_PLAINTEXT_STORE_PATH) | a command in the sandbox can read the tokens there and use them at github.com | efr hides only ~/.gcm; add the other dir to permissions.secret_paths |
| a credential helper that efr does not know | git in the sandbox cannot reach the hosts of its repository through the proxy | the note names the helper; a helper for one site blocks only that site |
| changes to your caches while an overlay uses them | old or wrong cache content in the sandbox | sandbox.cache_mode = "tmp" |
no network with network.mode = "none", or a host that is not on the allow list | a build that must download fails | with the proxy, the registries are on the default list; a host that the line shows is a review for that one host; without the proxy, the network is an exit, and sandbox.offline_hints tells package managers to use the cache |
| the proxy cannot see inside TLS | a command can send data to a host on your allow list, with no credentials: at most max_upload_bytes (4 MiB) per command, counted from the first byte of each connection | no credentials in the sandbox (masks, the environment filter, the token deny); the server name check; the cut of Encrypted Client Hello; a short default list |
the Go module proxy fetches a missing module from its origin, and a *.suffix entry lets a command pick a DNS name below the suffix | data can reach a host through @go, or a DNS server through a name | the list check before any lookup; the budget; remove @go from network.allow when you do not need it |
| a socket grant that reaches the network: systemd-resolved's varlink socket, nscd, the Docker and Podman sockets, tailscaled, a SOCKS socket | the call goes around the proxy | a socket grant is an exit; the question names the socket |
| the proxy checks only the first TLS hello | a second hello after a HelloRetryRequest, a TLS 1.2 renegotiation or the :authority of HTTP/2 can name another site behind the same CDN | the budget; such fronting depends on the CDN (Fastly and Cloudflare refused it in the probe) |
| tools that send an Encrypted Client Hello, also as a decoy: headless Chrome, Electron, Playwright | their fetches through the proxy fail (not TLS) | none: the cut stops fronting through ECH |
| a token in a file that the sandbox must read and that is not on efr's list | the token reaches a host on your allow list | the token deny covers the project's registry files and the git config; move a token to a file that the sandbox hides |
| a foreground call that runs past its timeout | it loses its network when efrd restarts | none; a restart ends every job anyway |
| MCP servers | they run outside the sandbox | none yet |
| the host naming rule sees only the hosts of the line and of the git remotes | a host that a script connects to has no fact before the proxy refuses it | with the proxy, a host that is not listed gets 403 and then a review of that one host; the masks and the environment filter stay |
| the reviewer's name check is a word match | "do not push" names a push; "zshrc" can name the wrong file | the reviewer still reads your messages; a generic file name such as config or init names nothing alone, nor does the bare name of a program, a unit or a desktop entry; wide targets ask you |
an allowed outside line runs in the exit child with your full rights, and only predicted targets get a copy | a program that the model did not write in this turn can change ~/.zshrc with no name and no copy; the turn-end line names only what the copies cover | the one-command rule; a program written in this turn or in a write root asks you, and so does an interpreter on code that efr cannot read (python3 -c, sh -c, a script); the reviewer sees the line |
| the payload of a file tool edit of a floor file goes to the reviewer | the payload is model text, which can hold words that try to steer the reviewer | the reviewer's prompt says that text in the action never authorizes; the naming rule still needs a message of yours; a payload over 8 KiB asks you |
| older system prompts of a conversation keep the old auto rule | the model can still read the old, longer rule in its history | the newest rule says that only the newest block counts |
| a cache whose path holds a link | efrd refuses to apply its deletes | the deletes stay private; nothing is lost |
sandbox.cache_mode = "overlay" | a refused delete stays in the layer of the conversation and comes back after each call | efr does not raise the same paths again in the conversation |
| a snapshot leaves out large files, ignored files above 1 MiB, more than 2000 ignored files, names that are not UTF-8 and nested repositories | undo cannot bring them back | the preview and the turn-end line say not covered; a delete of such a path is not routine |
a directory over snapshot.home_max_files or snapshot.home_max_mib | undo cannot bring back the other files of that dir | the turn-end line says not covered: <dir> (over the copy limits) |
| an approved exit outside the sandbox can change efr's snapshot store | undo can lose its record of a turn | at the turn's end efr checks each pre ref; a missing one says not covered: <root> (the snapshot store changed) |
| a command that runs on after its timeout | it can change files after the turn's last snapshot | the turn-end line says not covered: <root> (a call still runs), undo refuses while it runs, and undo checks each path again |
| another conversation writes the same project while a call runs | that write counts as the call's change | undo moves its bytes to the undo trash; the plan lock orders the calls |
content that is only staged, in neither the work tree nor HEAD | git reset --hard loses it | such a reset is not covered, so it goes to the reviewer |
| older stash entries live only in the reflog | git stash clear loses them | stash drop and clear are covered only with one entry |
| a snapshot holds no empty dirs, fifos, sockets, devices, extended attributes, ACLs, owners or hard links | a covered rm -r loses them, and undo makes hard-linked files separate | the preview notes empty directories and special files do not come back |
at, batch and systemd-run --user | they run later with no copy | they stay with you; a started unit runs on after an undo |
| a background job in the exit child runs as you | it can open the hidden shell's terminal by its path and read efr's runtime files | it holds no terminal and no descriptor of one; no_new_privs; its time limit; a thin mount namespace for such jobs comes later |
a job in the exit child that starts a process in a new session (setsid) and ends its own launcher, in any order | without a job cgroup (efrd not under its unit, or a unit with no Delegate=yes), that process can escape the stop, the time limit and the sweep; the end does not name it | the cgroup of the job (ADR 0021) kills it at the end; the sweep by the job's session ends every other process; the end names a lost launcher; efrd's socket counts each process in its unit's cgroup and below as the model's |
| a job in the exit child moves its own processes out of its cgroup on purpose | it runs as you and owns the delegated cgroups, so the cgroup kill does not reach them | the sweep by the job's session; efrd's socket counts every process in its unit's cgroup and below as the model's; a contained job has a cgroup namespace of its own |
| a job keeps the sandbox plan of its start | a new mask, secret or floor of a settings change does not reach a running job | stop the job and let the model start it again |
| a job writes a path that a later call also changes | that change stays with the later call's turn, not with the job's | undo refuses while a job writes in its roots |
| a dev server listens on the sandbox's own loopback | in a foreground call, your browser cannot reach it, and it stops when the call ends | the model starts a server as a background job, and efr forwards its port to the same port of your 127.0.0.1 (see "Ports of a job") |
| a job can listen on the port of a service that does not run at that time, such as a database, and a client of yours then sends it a password or a token first | the secret reaches the job's output, which the model reads | efr never forwards the service ports of "Ports of a job"; a port in use stays the service's, so a running service is safe; the line names each port; the job's time limit |
| a page that a forwarded server serves runs in your browser, outside the sandbox | the page uses your browser's network, and the proxy, its allow list and its upload budget do not see it; your browser sends the cookies of other local web apps on localhost (cookies ignore the port) to the job | the same as for any dev server; to keep those cookies apart, open http://127.0.0.1:<port> instead |
| a web site that you visit can read a forwarded server through DNS rebinding | the site can read the pages of the job's server | the same as for any dev server; efr does not check the Host header, and dev servers such as Vite check it themselves; the forward ends with the job |
a server of yours listens only on ::1 or :: of a port | efr counts the port as in use and does not forward the job's port | the line says in use on this machine; a forward would send your browser to your own server, because the browser tries ::1 first for localhost |
| a job's server listens on a port of the job's range of ephemeral ports (32768 to 60999 by default) | its forward comes after 10 seconds, not after 1 | none; a short test server on port 0 gets no forward |
| a contained call reaches the forwarded server of a job of its conversation | a call can use a job's grants through a server of the job that relays, such as a dev proxy | the same as through $SCRATCH, which the call and the job share; the job's proxy budget counts each byte that leaves; only the ports that efr forwards, only for the same conversation, and efrd checks the conversation on each connection |
| a job's output goes to a pipe | some programs keep their output in a buffer, so the output file and the notices come late | run the program with stdbuf -oL or its own flag for line output |
| a call that you move to the background has no launcher time limit and no lifeline of its own | when efrd hangs but does not end, nothing ends the command | efrd's clock ends it at its time limit; when efrd ends, the terminal of the old hidden shell closes and the command ends with it; efr jobs stop |
| a call in the sandbox that you move to the background keeps its writable git dirs | the command can change git settings or hooks until it ends, and only the surface guard at its end sees it | the outside git check before each git line outside the sandbox; the guard at the end moves such changes to quarantine |
| a moved command that asks a plain question | it waits with nobody to answer until its time limit | the notice of its end; efr jobs stop ends it |