Dewei Zhai

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:

  • .env and ~/.zshrc, where one wrong git add commits 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:

  1. The credential lives somewhere encrypted at rest, unlocked once when I log in.
  2. 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 security calls. 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-secrets field. secret list filters on it, so I see what I put there — not the OS clutter.
  • Fails loudly. secret get exits 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 before exec — 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 export lives 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 -l lets 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 separate credentials.md for the agent to keep in sync.
  • Non-interactive by design. secret get works with no tty; secret add reads 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.