Trust Model¶
No config runs until you approve it, and any edit to it revokes that approval. That is the whole rule. The rest of this page is where it is enforced and what each command prints.
envoke allow # review every config envoke would load, then trust them
envoke allow /path/to/config # ...or just that one
envoke list # the set, each one's status, then any leftover records
envoke revoke # withdraw trust for the whole set
envoke prune # drop records whose config file is gone
The five things that follow from the rule:
- Approval is per file. Approving your central config says nothing about a
fragment symlinked in from a repository you cloned.
envoke allowwith no path still covers all of them at once, behind a single confirmation. - Approval is of content. It is a SHA-256 of the file's bytes, so any edit — whitespace included — revokes it until you approve again.
- Nothing is ever discovered. envoke loads configs only from your own config directory, so there is no prompt on the way into a directory, and no file you did not put there can ask to be trusted.
- A skipped config never stops the others. It is reported on stderr, the trusted configs still run, and nothing executes from the untrusted one.
envoke debugis exempt. It reports what would fire without executing anything, trusted or not, so it is safe against a config you are mid-edit.
Nothing is discovered; everything is approved¶
envoke only ever loads configs from your own config directory — the
central config, and the files in envokerc.d. It does not read a config
because you walked into the directory holding it, so no file you did not put
there can ever ask to be trusted.
That is why there is no prompt on the way in. An unapproved config is reported and skipped:
envoke: 1 block(s) matched for /work -> /work/api but /home/you/.config/envoke/envokerc.d/10-work is not trusted: run `envoke allow /home/you/.config/envoke/envokerc.d/10-work`
A config committed inside a project joins the set only through a symlink you
create yourself (see
Bringing a project's own config in).
Its content still has to be approved, and it is confined to the project's own
directory tree — a git pull that rewrites it revokes its approval and cannot
widen where it applies. That bound is not left implicit either: envoke allow's
review states it before asking you to confirm, and envoke debug prints it
under the config's status line.
Text printed from a config that has not been approved — patterns, script bodies, its path, and any error quoting them — is escaped first, so an escape sequence in a file or a directory name cannot redraw what you are reading.
Approving a config¶
envoke allow # every config envoke would load
envoke allow /path/to/config # just that one
With no path, envoke allow covers the whole set: the central config plus
every envokerc.d fragment. Splitting rules across files is an organisational
choice, not a decision to approve them one at a time — you review each in turn
and answer once, as the second transcript below shows.
envoke allow refuses to trust a config that doesn't even parse — you can't accidentally approve something broken.
Before recording trust, envoke allow shows you what you're about to approve (see below for the first-time-vs-re-approval cases) and then asks for confirmation:
envoke: trust and run these blocks on every matching cd? [y/N]
Only y or yes (case-insensitive) proceeds; anything else — a different answer, an empty line, or closing stdin (EOF) — aborts without trusting anything:
$ envoke allow
envoke: about to trust /home/you/.envokerc -- review each block below before confirming:
enter ~/Projects/([^/]+) (line 1)
source "$ENVOKE_DIR/venv/bin/activate"
leave ~/Projects/([^/]+) (line 4)
deactivate
envoke: trust and run these blocks on every matching cd? [y/N] y
envoke: trusted /home/you/.envokerc
envoke: to apply it to this shell without leaving the directory: eval "$(envoke reload)"
Answering anything but y/yes prints envoke: aborted, not trusted to stderr and exits non-zero, leaving the config untrusted.
That last line is there because envoke allow is a child process of your shell and cannot export anything into it — what you just approved applies from your next cd onwards. See Applying a config without leaving the directory.
With a set, the shape is the same and there is still one prompt. Every
config is reviewed first, in set order, and the [y/N] at the end covers
everything still pending:
$ envoke allow
envoke: about to trust /home/you/.envokerc -- review each block below before confirming:
enter ~/work/([^/]+) (line 1)
. "$ENVOKE_DIR/venv/bin/activate"
envoke: /home/you/.config/envoke/envokerc.d/10-work is unchanged since it was last trusted -- nothing to review
envoke: /home/you/.config/envoke/envokerc.d/20-python: line 3: enter ~/Projects has no script body
envoke: trust and run these blocks on every matching cd? [y/N] y
envoke: trusted /home/you/.envokerc
envoke: to apply it to this shell without leaving the directory: eval "$(envoke reload)"
Three things to read there: the config already trusted verbatim is reported and
skipped, since there is nothing to confirm about it; the one that doesn't parse
is reported on stderr and skipped, without stopping the others; and answering
y trusts everything that was pending. The exit code is 1 all the same,
because something was skipped — see
Exit codes.
For non-interactive use — dotfiles bootstrap scripts, CI, provisioning — pass --yes (or -y) to skip the prompt and trust immediately, the same as answering y:
envoke allow --yes
envoke allow -y /path/to/config
The flag may come before or after the path — envoke allow --yes ~/.envokerc and envoke allow ~/.envokerc --yes both work.
Reviewing a symlinked project fragment¶
A fragment symlinked in from a project is reviewed through the link: the
file envoke parses, hashes and prints for you is the target, since that is the
file that will be loaded on every cd. Approving content whose displayed
meaning differs from its effective one would approve nothing. So the review
names the target, and states the tree the config's blocks are
confined to, before the
prompt:
$ envoke allow
envoke: about to trust /home/you/.config/envoke/envokerc.d/api -- review each block below before confirming:
enter ./src (line 1)
export SRC=1
note: symlink to /home/you/work/api/envoke.conf
note: confined to /home/you/work/api -- its blocks cannot match outside that directory, whatever their patterns say
envoke: trust and run these blocks on every matching cd? [y/N]
Both notes appear on the diff shown for a config that changed, too — which is
the path a git pull that rewrote a project's config actually takes, and the
one where the bound matters most. For your central config, or an ordinary file
in envokerc.d, there is no link to resolve and no bound to state, so neither
note appears. envoke debug reports the same two facts under each
config's status line, which is where to look when a config reads trusted and
still fires nothing — see envoke debug.
Seeing and withdrawing trust¶
envoke list # what's trusted, and whether it still matches
envoke revoke # withdraw trust for the whole set
envoke revoke /path/to/config # ...or for one config
envoke prune # drop records whose config no longer exists
revoke is allow backwards, defaults included: with no path it covers the
same whole set envoke allow does — the central config plus every
envokerc.d fragment — and with a path, only that file. Neither command has a
different idea of what "the set" is, so approving a set and then withdrawing it
leaves nothing behind. What neither touches is a record for a config outside
the set; those are the second half of envoke list's output, and envoke
prune is what clears the ones whose file is gone.
envoke list answers two questions that are not the same: what envoke would
load, and what the store has recorded. A config can be loaded with no
record at all — that is a file being skipped on every cd — and a record can
outlive the config it was written for. So the two are listed separately:
$ envoke list
envoke: configs envoke would load
trusted central /home/you/.envokerc
changed fragment /home/you/.config/envoke/envokerc.d/10-work
untrusted fragment /home/you/.config/envoke/envokerc.d/20-python
envoke: other trust records (not in the current config set)
missing /home/you/old-project/envokerc
For a config in the set, the status is what would happen to it on your next
cd:
- trusted — the file's current content is what you approved, so its blocks will run.
- changed — approved before, edited since. Nothing runs until you
envoke allowit again, and you'll get a diff rather than a full re-read. - untrusted — never approved. Nothing runs until you review it.
- missing / unreadable — the file was listed but couldn't be read. For a fragment that usually means a broken symlink.
The second section is everything else the store holds. It is not an error list:
a record for a config you keep under a different $ENVOKERC, or one you have
since split into fragments, belongs there legitimately. But it is where a stale
record shows up:
- missing — the config file is gone, though its record (and the copy of its
content, see below) is still in the store.
envoke pruneclears those. - trusted / changed / unreadable — the file is still there, envoke just isn't loading it right now.
envoke revoke puts a config back to needing an explicit approval, without
having to edit it or delete files out of the store by hand. Revoking
something that wasn't trusted is a no-op, not an error.
Records approved by an older envoke
Records written before envoke started storing the config's path can't be
resolved back to a file. envoke list shows them as unknown with their
store path, and envoke prune deliberately leaves them alone rather than
guessing — re-run envoke allow on the config to replace such a record,
or delete the file it names.
The store keeps a copy of what you approved¶
envoke allow writes the approved content into the trust store so it can
show you a diff next time. That is a plaintext second copy of your
config, and since exporting project-scoped secrets is one of envoke's main
uses, that copy may well contain them. It's written 0600 in a 0700
directory, but it does mean deleting a config isn't the whole story:
envoke revoke /path/to/config # removes the record and its content copy
envoke prune # same, for configs already deleted
Re-approving a changed config¶
What envoke allow shows you before the confirmation prompt depends on whether the config was trusted before, and whether it's changed since:
- First time trusting this config — the full block-by-block dump shown above: every block's type, pattern, source line, and script body.
- Trusted before, content byte-for-byte unchanged — nothing to review.
envoke allowprints a one-line status and returns immediately, without prompting and without touching the trust record again (it's already trusted):$ envoke allow envoke: /home/you/.envokerc is unchanged since it was last trusted -- nothing to review--yesis a no-op here, since there's no prompt to skip. - Trusted before, content changed — a line-level diff against the previously-approved content, instead of the full dump, so a small edit to an already-trusted config doesn't require re-reading the whole file:
Lines prefixed
$ envoke allow envoke: /home/you/.envokerc changed since it was last trusted -- here's what's different: - echo old-line + echo new-line envoke: trust and run these blocks on every matching cd? [y/N]-were removed, lines prefixed+were added — the same convention asdiff -u/git diff. Unchanged lines are omitted entirely. The[y/N]prompt (or--yes) still applies in this case, same as first-time trust.
How trust is tracked¶
Trust is a SHA-256 hash of the config file's content, recorded under $XDG_DATA_HOME/envoke/allow/<sha256(abs path)> (or ~/.local/share/envoke/allow/... if $XDG_DATA_HOME isn't set) — one record per config path, so distinct configs never collide. Each record is three files:
| File | Holds | Used for |
|---|---|---|
<sha256(abs path)> |
the approved content's hash | the trust decision itself |
<sha256(abs path)>.content |
a copy of the approved content | the diff on re-approval |
<sha256(abs path)>.path |
the config's absolute path | envoke list / envoke prune |
Both siblings are optional on read, so upgrading envoke never revokes an existing approval — a record with no siblings is a normal state, not corruption. The hash file is always written last, and every file is written atomically, so an interrupted write leaves the config untrusted rather than trusted against content it doesn't describe.
When envoke shell-hook runs, it recomputes the current file's content hash and compares it to the trusted record:
- Match → the resolved blocks are rendered into your shell's dialect and
eval'd by your shell. - No match, or no record at all → nothing executes. envoke reports the untrusted match on stderr only (never stdout), along with an
envoke allow <path>hint, and stops there.
Any edit to the config — even whitespace — changes the content hash and revokes trust until you run envoke allow again. This means there's no way to silently smuggle a change into an already-trusted config; every modification requires a fresh, explicit approval.
The config is read exactly once per command¶
Both envoke allow and envoke shell-hook read the config file a single
time and use those same bytes for everything they do with it — parsing it,
showing it to you, hashing it, and rendering it into your shell. That is a
security property, not an implementation detail: reading the file more than
once would open a window between the read that gets validated and the read
that gets executed, so a config could be run in one version while being
approved in another. On a config another local user can write to — exactly
what the permission warning below is about — that window is reachable, so
the trust check operates on bytes already in hand rather than on a path it
re-opens.
File permission warnings¶
Content-hash revocation protects you from silently running a config that changed since you last trusted it — but on a shared machine (multi-user box, NFS home), nothing stops another local user from editing a config you've already approved. So envoke checks whether the config file is writable by anyone other than its owner (group or other write bits set) and prints a non-fatal warning to stderr if so:
envoke: warning: /home/you/.envokerc is writable by group/other (mode 664) -- consider tightening its permissions
Which configs get checked depends on the command, and the split is the hot
path: envoke allow and envoke debug check every config in the set,
while envoke shell-hook and envoke reload check only the ones that
actually matched the directory change in front of them.
envoke allow and envoke debug additionally check the directory the
config lives in, which is the stronger signal of the two: a config whose own
mode is 644 looks fine, but anyone who can write the directory holding it
can rename it away and drop their own file in its place, which the file's
permissions say nothing about.
envoke: warning: the directory /home/you/.config/envoke/envokerc.d is writable by group/other (mode 777) -- anyone who can write it can replace 10-work outright; run `chmod go-w /home/you/.config/envoke/envokerc.d`
The shell hook deliberately skips that second check. It runs on every
directory change, and the directory in question is your own config
directory — paying a syscall per config per cd to report something that can
only be true if you made it true is the wrong trade. The commands you actually
read the output of are where it fires.
These are warnings, not blocks — fix them with chmod go-w if you see one
unexpectedly.
The same check runs against the trust store directory itself, and that one matters more:
envoke: warning: the trust store /home/you/.local/share/envoke/allow is writable by group/other (mode 777) -- anyone who can write there can forge an approval; run `chmod go-w ...`
A writable config can be tampered with, but the tampering revokes its own
trust — the content hash stops matching. A writable store lets someone
drop in a record that makes any config read as trusted, forging an approval
you never gave. envoke creates the store 0700, but that only applies to
directories it actually creates: a pre-existing ~/.local/share tree, or an
$XDG_DATA_HOME with loose permissions, keeps whatever mode it already had.
This check covers the directories above the store too, up to the data home
— and names whichever one is actually writable. A 0700 store inside a
0777 parent is a 0777 store: anyone who can write the parent can rename
the store away and put their own in its place, records and all. The walk
stops at the data home, because a writable directory above that means your
whole home is writable, which is not a fact about envoke.
None of these warnings fire on Windows
They are a Unix answer to a Unix question. Windows governs access
through ACLs, which Go's os.Stat does not report: it makes the
permission word up from the read-only attribute alone, so every writable
file reads as 0666 and every directory as 0777. Testing the
group/other bits against that would flag every config and the store
itself on a perfectly ordinary machine — and, since the store check runs
on the path every cd takes, print a warning at every prompt.
So envoke says nothing there rather than something false. Reading the
real ACL would mean a dependency outside the standard library, which is
a large price for a warning. If you share a Windows machine, check the
store's permissions yourself — icacls %USERPROFILE%\.local\share\envoke,
or wherever $XDG_DATA_HOME points if you set it — and expect no entry
beyond you, SYSTEM and Administrators.
Directory names are never executed¶
The trust model only means something if the only code envoke can run is code you approved. A directory name is attacker-controllable in ordinary situations — an extracted archive, a cloned repository, a shared or NFS home — so no shell hook may ever let one reach a shell parser as code.
Every generated hook passes the two directories to envoke shell-hook
without any string interpolation into something that gets re-parsed. tcsh is
the awkward one: its cwdcmd alias can only pipe into source from inside
an eval, and eval re-parses its argument. The hook therefore keeps that
eval string a fixed constant and passes the directories through the
environment instead:
setenv ENVOKE_FROM "$owd" ; setenv ENVOKE_TO "$cwd" ; eval "\envoke shell-hook --shell tcsh | source /dev/stdin" ; unsetenv ENVOKE_FROM ; unsetenv ENVOKE_TO
envoke shell-hook reads $ENVOKE_FROM/$ENVOKE_TO when it is given no
positional arguments; explicit arguments always take precedence. This is
covered by a cross-shell regression test that cds a real bash, zsh, fish,
tcsh and PowerShell into a directory whose name is packed with shell
metacharacters and asserts nothing was executed.
Why this is non-negotiable¶
Any tool that runs arbitrary shell code on cd needs an opt-in step before it
executes for the first time — direnv has one (direnv allow), and ondir has
none at all: any ~/.ondirrc runs unconditionally, so cding into a directory
with a malicious or accidentally-broken config runs its script with no warning.
Trust-before-execution is one of envoke's core design principles: no code path
is allowed to auto-execute an unapproved config, including "convenience" paths.
If you're ever unsure what a config would do before trusting it, use
envoke debug — it reports matches without executing anything,
trusted or not.