|
| 1 | +# 32. Use fnox for Secrets |
| 2 | + |
| 3 | +Date: 2026-04-24 |
| 4 | + |
| 5 | +## Status |
| 6 | + |
| 7 | +Accepted |
| 8 | + |
| 9 | +## Context |
| 10 | + |
| 11 | +Environment variables that hold secrets (API keys, tokens) had been a loose mess. The previous approach lived in `config/fish/conf.d/secrets.fish` and looked like this: |
| 12 | + |
| 13 | +```fish |
| 14 | +set -gx OPENAI_API_KEY "op://Personal/OpenAI chatblade/credential" |
| 15 | +set -gx WPSCAN_API_TOKEN "op://Personal/Wpscan/Token/Token" |
| 16 | +``` |
| 17 | + |
| 18 | +The problem: those aren't resolved values, they're literal strings. Only tools that natively understand `op://` syntax (or commands wrapped in `op run`) can do anything useful with them. For everything else, `$OPENAI_API_KEY` was just the text `op://Personal/OpenAI...`. |
| 19 | + |
| 20 | +The obvious fix is to wrap shells in `op run` or have direnv call `op read`, but the 1Password CLI has a lifecycle problem on macOS: every resolution wants Touch ID. That's fine for one-shot CI runs or scripted secret retrieval. It's painful for env vars that get re-read constantly by shell hooks and child processes. |
| 21 | + |
| 22 | +What's needed is something that can fetch a secret once, cache it at the OS level, and hand it to the shell without prompting every time. |
| 23 | + |
| 24 | +## Decision |
| 25 | + |
| 26 | +Adopt [fnox](https://fnox.jdx.dev/) for shell-scoped secrets, with macOS Keychain as the default provider. |
| 27 | + |
| 28 | +Keychain is the right primary because once a secret is stored and the keychain is unlocked (which happens at login), reads are free. No Touch ID, no menu-bar prompts, no lifecycle pain. |
| 29 | + |
| 30 | +fnox also supports 1Password natively, so when a secret legitimately needs to live in 1Password (shared vault, rotation policy, etc.) it can reference `op://` paths through fnox instead of through raw env strings. That's available, just not the default. |
| 31 | + |
| 32 | +### Convention: Unique provider names across nested configs |
| 33 | + |
| 34 | +fnox walks up from the current directory and merges every `fnox.toml` it finds. Inner configs override outer configs at the key level, which means if two `fnox.toml` files both declare `[providers.keychain]`, the inner one wins silently. |
| 35 | + |
| 36 | +This bit us on 2026-04-24. `~/pickleton/fnox.toml` had `[providers.keychain]` with `service = "pickleton"`, and a nested `fnox.toml` declared the same provider name with `service = "dotfiles"`. From inside the nested directory, `RUNLAYER_API_KEY` resolution failed because fnox was now looking it up in the wrong keychain service. Warning logged, env var unset, confusing afternoon. |
| 37 | + |
| 38 | +Rule: name providers by their scope, not their type. `[providers.pickleton]` and `[providers.dotfiles]`, not `[providers.keychain]` in every config. |
| 39 | + |
| 40 | +### Scope |
| 41 | + |
| 42 | +fnox configs currently live per-project (one at `~/pickleton/fnox.toml`). `RUNLAYER_API_KEY` is the first secret managed this way. A global config for truly personal secrets is a reasonable future addition, but hasn't been set up yet. |
| 43 | + |
| 44 | +### Alternatives Considered |
| 45 | + |
| 46 | +1. **`op run --env-file=...`** |
| 47 | + - Pros: Resolves op:// paths at command-launch time |
| 48 | + - Cons: Touch ID per run, doesn't fit the "env var that's always set" shape |
| 49 | + - Rejected: the prompting cadence |
| 50 | + |
| 51 | +2. **direnv + `op read`** |
| 52 | + - Pros: Per-directory env, good ecosystem |
| 53 | + - Cons: Same Touch ID problem, plus direnv reloads are frequent |
| 54 | + - Rejected: same reason |
| 55 | + |
| 56 | +3. **Raw `op://` strings in fish (the old way)** |
| 57 | + - Pros: No moving parts |
| 58 | + - Cons: Nothing actually resolves them; tools see literal strings |
| 59 | + - Rejected: this is what got us here |
| 60 | + |
| 61 | +4. **sops + age for git-committed encrypted secrets** |
| 62 | + - Pros: Version-controlled, reviewable |
| 63 | + - Cons: Wrong shape for personal env vars; designed for team-shared config that lives in repos |
| 64 | + - Rejected: different use case, might still make sense elsewhere |
| 65 | + |
| 66 | +## Consequences |
| 67 | + |
| 68 | +### Positive |
| 69 | + |
| 70 | +- Secrets resolve to real values in shells, no prompting in the hot path |
| 71 | +- Keychain unlock state handles auth once per login session |
| 72 | +- Provider is swappable; 1Password stays available for secrets that belong there |
| 73 | +- Shell integration reacts to `cd`, so per-project secrets come and go with the directory |
| 74 | + |
| 75 | +### Negative |
| 76 | + |
| 77 | +- First-time keychain reads may still prompt once (acceptable) |
| 78 | +- Provider-naming collisions are a silent footgun until you know to watch for them |
| 79 | +- No global config for personal secrets yet, so anything truly global has to wait or live per-project |
| 80 | +- Another tool in the chain; if fnox's shell hook breaks, env vars go with it |
| 81 | + |
| 82 | +### Migration |
| 83 | + |
| 84 | +`config/fish/conf.d/secrets.fish` was removed as part of this decision. It held three `op://` literals (`OPENAI_API_KEY`, `WPSCAN_API_TOKEN`, `NPM_TOKEN`) that weren't actually in use. |
| 85 | + |
| 86 | +## Links |
| 87 | + |
| 88 | +- [fnox docs](https://fnox.jdx.dev/) |
| 89 | +- [fnox 1Password provider](https://fnox.jdx.dev/providers/1password) |
0 commit comments