Design Notes¶
ondir is feature-complete by its own maintainer's account and still works for plenty of people, but it's had no release in years. This page lists the specific, concrete points where envoke's design departs from it — not a bug list, a rationale for a rewrite instead of a fork:
| ondir behavior | envoke's approach |
|---|---|
| Worst-case (exponential-time) matching on some patterns, via glibc's POSIX regex | Go's regexp (RE2), linear-time matching guaranteed |
Basename-prefix matching (/home/foo matches /home/foobar) |
Path-segment matching, not raw string prefix |
No ~ expansion in config paths |
Explicit ~/env-var expansion |
| Capture groups not exposed to scripts | Matched path and capture groups exposed as env vars |
No ONDIRRC var / no XDG support |
ENVOKERC env var + $XDG_CONFIG_HOME/envoke/config |
Config only ever at ~/.ondirrc |
Also an envokerc.d directory of fragments, with ./-relative patterns and symlinked project configs |
| Hand-maintained shell scripts per shell (bash/zsh/tcsh/fish) | Single binary generates hooks: envoke shell-init bash\|zsh\|fish\|tcsh\|powershell |
zsh integration overrides cd directly |
Proper chpwd_functions (zsh), --on-variable PWD (fish), cwdcmd (tcsh), wrapped prompt (PowerShell) |
| No config trust/opt-in | envoke allow before executing new/changed config |
Non-negotiable principles¶
These hold across the whole codebase, not just as a starting point:
- RE2 matching only — always Go's standard
regexp, never a backtracking engine (backtracking engines can take exponential time on certain patterns; RE2 guarantees linear time regardless of the pattern). - Path-segment matching, not string-prefix matching — a pattern must match whole path segments.
- Enter/leave are independent and explicit — no automatic state snapshot/restore on leave.
- Trust before execution — any new or modified config must go through an explicit
envoke allowbefore its blocks run, with no exceptions for "convenience" code paths. - Never discover a config in a directory the user doesn't own — every file envoke loads lives under the user's own config directory. A config committed in a project joins the set only through a symlink the user creates, never by
cd-ing anywhere. - A config that points out of the config directory is confined to its target's subtree — a symlinked project config is content someone else's commit can rewrite, so no pattern in it can make it fire outside the project it came with.
- One binary generates all shell integration — hook scripts for every supported shell are generated by the binary itself, not maintained as hand-written per-shell files.
- A config feeding a trust decision is read exactly once — the same bytes are parsed, shown to you, hashed and executed. Reading the file a second time would open a window between the read that gets validated and the read that gets run, which on a config another local user can write to is reachable. See The config is read exactly once per command.
Why envoke does not go looking for configs¶
The "never discover a config in a directory the user doesn't own" principle is the one that cost the most to arrive at, so it is worth recording what it replaced.
An earlier version of the envokerc.d work did what direnv does: it looked
for a config file in every directory you walked through, and asked — right
there in your terminal — whether you trusted one it hadn't seen before.
Three things went wrong with that, and they compound:
- A file in a world-writable directory is nobody's config. A
.envokercdropped into/tmpby another local user is mode0644, owned by them. Checking the file's own permissions says nothing: they are perfectly ordinary. Yet every user whocd'd anywhere under/tmpwas shown a trust prompt for a file they did not write, did not control, and could not fix. direnv has the same exposure. That did not make it acceptable; it meant there was no prior art to copy. - The prompt was the weakest possible place to make a security decision. It appeared unbidden, in the middle of unrelated work, about a file the user had never heard of — and its contents came from that same file, so a directory name or a block body containing terminal escape sequences could redraw the question being asked.
- A prompt needs someone to answer it. A script that
cds, launched from a terminal it inherits as stdin, could block forever on a question nobody was there to read.
So the model changed rather than the prompt being hardened. envoke now loads
only what is in your own config directory, and a config that travels with a
project joins the set through a symlink you create — an act you perform
once, knowingly, rather than one a cd performs for you. There is no
prompt, because there is no longer a question envoke has to ask you at an
arbitrary moment: a file is either already approved or it is one
envoke allow away.
That symlink is the one remaining path by which executed content comes from
somewhere other than a file you wrote, and it is deliberately gated three
ways: you create the link, so nothing joins the set without a deliberate
act; the file's content still has to go through envoke allow, and any later
edit revokes that approval; and the config is confined to its own subtree, so
no pattern in it can fire outside the project it came with. See Trust
Model. Anyone proposing a return to filesystem discovery has to
answer the /tmp case first.