2026-05-15
Secrets in the Keychain, not in your .env
A 230-line Bash wrapper around the macOS Keychain that lets me inject API keys into one command at a time — without ever exporting them to my shell or committing them to .env.
The problem
If you do anything with cloud APIs, databases, or AI agents, you end up holding a lot of long-lived credentials. They tend to settle in exactly the wrong places:
.envand~/.zshrc, where one wronggit addcommits them.export PGPASSWORD=..., which then lives in your shell environment and is readable by every child process for the rest of the session.- Your shell history, where
mysql -p'…'and friends sit in plain text.
The deeper issue: there is no friction-free way to inject a secret
into one command and only that command without it spilling into the
ambient environment. AI agents make this worse — they shell out a
lot, and any value you export lands inside the agent’s blast
radius.
What I actually want
Just two things:
- The credential lives somewhere encrypted at rest, unlocked once when I log in.
mycommand --token=$(secret get foo) ...works. The value flows into that one command, then dies.
That’s it. No daemon. No master password. No new trust root.
Why the usual fixes fall short
A few things people reach for, and where each one stops short:
- dotenv files. Encryption story is “don’t commit it.” Once
loaded, still
export-ed into the shell. pass/gopass. Strong tools, but you bring in GPG keys, an agent, and a separate trust root. Overkill if the laptop already has an OS keychain.- 1Password CLI. Great if you pay for it. Adds a network-backed dependency and a session token to keep alive.
- Raw
securitycalls. macOS already has a Keychain. The binary works (security find-generic-password -a foo -s bar -w), but it is awful to type, returns nothing on a missing key, and silently mixes your secrets in with every Wi-Fi password and Safari login the OS has stored.
The fix
A 230-line Bash wrapper around security. Four design choices that
turn raw Keychain access into something I actually reach for.
- OS-level encrypted storage. Values live in
~/Library/Keychains/login.keychain-db. Encrypted at rest, unlocked by my macOS login. No new daemon, no master password. - Inject in place. The recommended pattern is
$(secret get name)— the value flows into one command and dies with it. Never enters my shell environment, history, or any child process beyond the one I intended. - Namespaced from the rest of the Keychain. Every entry uses a
fixed
account=agent-secretsfield.secret listfilters on it, so I see what I put there — not the OS clutter. - Fails loudly.
secret getexits 1 with a stderr message if the entry is missing, empty, or the Keychain is locked. It will not silently return an empty string and let a script fire an unauthenticated request at production.
What it looks like in practice:
# Stash once, with a description
secret add aliyun-prod-access-key "Aliyun prod root AK"
# Use without leaking
ALIBABA_CLOUD_ACCESS_KEY_ID=$(secret get aliyun-prod-access-key) \
aliyun ecs DescribeInstances
# Browse what you have
secret list -l
Code is open: zhaidewei/secret-cli.
Why this is agent-friendly
The shape ends up being a particularly good fit for coding agents and other shell-out tools:
- Credentials never enter the agent’s context. When the agent
runs
cmd --token=$(secret get foo), the shell substitutes the value beforeexec— the literal string the agent sees, logs, and replays is$(secret get foo). No raw credential ever lands in the conversation transcript. - Smallest blast radius for shell-out. Agents shell out
constantly. Anything you
exportlives the whole session and is inherited by every sibling tool call.$(secret get …)lives one command and dies — if the agent goes off the rails later, the credential isn’t still sitting in the environment to be exfiltrated. - Loud failure is what an agent needs. Agents lack the human “wait, why is this empty?” instinct. Exit 1 plus a stderr message lets the agent notice and ask for help, instead of silently firing an unauthenticated request at production.
- Self-describing inventory.
secret list -llets the agent discover what’s available and what each entry is for — the description field doubles as machine-readable metadata, so I don’t have to maintain a separatecredentials.mdfor the agent to keep in sync. - Non-interactive by design.
secret getworks with no tty;secret addreads from stdin if it’s piped. Nothing stalls on “press any key to continue.”
What I take away
Most of the heavy “secret managers” assume you don’t have an OS to lean on. On a Mac you do, and you’ve already paid for it with your login password. The interesting work was the thin ergonomic layer on top — namespacing, fail-loud, one-shot injection — not the storage. The cheap move was to stop fighting the OS and wrap it instead.
Got thoughts on this? Argue with my agent, or send me a note.