fenn
Run a command with secrets fetched just-in-time, injected leak-tight, and gone on exit — plus a safe, scoped direnv. Rust.
1passwordclideveloper-toolsdirenvdotenvenvironment-variablespassrustsecret-injectionsecretssecrets-managementsecuritysopszeroize
fenn
Run a command with secrets fetched just-in-time, injected leak-tight, and gone on exit. Your pointer file holds references, never values.
$ cat .fenn.env
GITHUB_TOKEN=cmd://gh auth token # run any CLI, capture its stdout as the secret
DATABASE_URL=env://DB_URL # or pull from an existing env var
LOG_LEVEL=debug # no scheme -> a literal, passed through
$ fenn exec -- printenv GITHUB_TOKEN
gho_xxxx… # the child sees it…
$ printenv GITHUB_TOKEN
# …your shell never does
Why
A .env file with real secrets sits in plaintext on disk, one git add . from a
leak. fenn keeps only pointers on disk and fetches the real values just before
they're needed, into one child process's environment, gone when it exits.
Hygiene guarantees (the point of the tool)
- Never on argv — values go into the child's environment, never a command line, so they can't be read from
ps//proc. - Never on disk — env injection needs no temp files.
- Never in your shell — secrets land in the child only; your interactive shell and this process's own environment stay clean.
- Can't leak by accident — the
Secrettype prints<redacted>forDebug/Displayand zeroizes its buffer on drop. - Fail closed — if any reference fails to resolve, fenn runs nothing.
Honest limit: a running process holds secrets in memory by nature. fenn shrinks the exposure window and defeats accidental leaks; it does not defeat a live-memory dump.
Backends
cmd://<command> is the primitive — shell out to any backend's own CLI. Native
schemes are thin rewrites onto it (see src/resolver/rewrite.rs):
op://Prod/stripe/key -> op read op://Prod/stripe/key
pass://prod/db -> pass show prod/db
sops://file.yaml#a.b -> sops --decrypt --extract '["a"]["b"]' file.yaml
sops://file.yaml#s[0].h -> sops --decrypt --extract '["s"][0]["h"]' file.yaml # array index
sops://file.yaml -> sops --decrypt file.yaml # whole decrypted file
env://VAR reads an existing environment variable.
cmd:// execs an argv directly — no shell, so no pipes or redirection. When you
genuinely need them, sh://<line> runs the line through sh -c (opt-in, weaker
posture — every shell metacharacter is live, so keep it to trusted config):
sh://vault read -field=token secret/ci | tr -d '\n'
Directory hook — a safe direnv
direnv auto-loads a folder's vars, but into your interactive shell — ambient and
leaky (they sit in /proc/self/environ, visible to every process you launch). fenn
fills the quadrant nobody occupies: automatic and scoped. In a trusted folder
the hook wraps declared commands so they run under fenn exec — the secret reaches
only that child, never your shell.
$ cat .fenn.env
# fenn: wrap npm node # commands to auto-wrap in this folder
NPM_TOKEN=cmd://gh auth token
$ eval "$(fenn hook zsh)" # once, in your ~/.zshrc (or: fenn hook bash)
$ cd myproject
fenn: .fenn.env present but not allowed here — run `fenn allow`
$ fenn allow # trust it — pins the file's hash
$ npm publish # runs as `fenn exec -- npm publish`; NPM_TOKEN in npm only
$ printenv NPM_TOKEN # your shell still sees nothing
Trust is content-hashed: edit .fenn.env and the hook goes cold until you
fenn allow again, so a pointer file in a freshly cloned repo does nothing on its
own. fenn deny revokes.
Try it in a throwaway sandbox (fake secret, no real vaults): scripts/demo.sh.
Commands
fenn init [-f <file>] [bash|zsh] # scaffold a .fenn.env + print the hook line
fenn exec [-f <file>] [--no-inherit] -- <cmd> [args…] # resolve + run
fenn check [-f <file>] # resolve, report ok/fail by name (no values)
fenn list [-f <file>] # show names + reference URIs (no values)
fenn allow [dir] / deny [dir] # trust / untrust a directory for the hook
fenn hook <bash|zsh> # print the shell integration for your rc
Status
Resolvers: cmd://, sh://, env://, op://, sops://, pass://. Directory
hook with content-hash trust (bash/zsh). SIGTERM/SIGHUP forwarded to the child;
resolver buffers zeroized.
Apache-2.0.