Skip to content

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 allow before 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 .envokerc dropped into /tmp by another local user is mode 0644, owned by them. Checking the file's own permissions says nothing: they are perfectly ordinary. Yet every user who cd'd anywhere under /tmp was 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.