Secret Management in the Age of AI Coding Agents
software development
Srđan Marković
CTO
A practical architecture for securing developer credentials against AI agent context ingestion, using a zero-trust shell configuration pattern with macOS Keychain.
AI coding agents read your shell configuration files. Every agent with terminal access - Cursor, Claude Code, Gemini CLI, Codex, Windsurf - can see your .zshenv, .zshrc, and .bashrc. These files are sometimes synced across machines, and visible in process memory to any tool that reads environment variables. If they contain hardcoded API keys (and those keys need to live somewhere), those keys are exposed across all of these surfaces.
This is what most developer environments look like today:
# ~/.zshrc
export BRAVE_API_KEY="BSAx_g0_XUZ...ue5"
export CONTEXT7_API_KEY="ctx...7da"
export ELEVENLABS_API_KEY="sk_33c...f38"
export HF_TOKEN="hf_rAf...lzU"
export KAGGLE_API_TOKEN="KGAT_09c...7b4"
export MATHPIX_APP_KEY="4eb...366"
Here is what yours will look like if you accept the idea behind this article:
# ~/.zshrc
for _key in "${_SECRETS[@]}"; do
export "$_key"="$(_keychain_get "$_key")"
done
No values anywhere in the file. The credentials live in macOS Keychain, resolved at interactive shell startup. An AI agent reading your dotfiles sees function calls and variable names. The raw credential never appears.
The Architecture
Your shell config should reference keys, not contain them. The values live in macOS Keychain and are resolved dynamically.
Why .zshenv and .zshrc Must Be Separate
During our internal implementation, we learned a critical lesson about timing. The .zshenv file runs for every shell invocation: non-interactive subshells, script executions, IDE background processes. Keychain lookups in that context fail silently.
The solution is a clean architectural split:
.zshenvis purely declarative. It defines the_SECRETSarray and helper functions. No values, no I/O..zshrcis the executor. It runs only for interactive shells, where Keychain access is reliable. This is where loading occurs.
Figure 1: .zshenv defines names and functions. .zshrc resolves values from macOS Keychain at interactive startup.
Here is the complete .zshenv:
# ~/.zshenv - key names and helper functions (no values)
_SECRETS=(
BRAVE_API_KEY
CONTEXT7_API_KEY
ELEVENLABS_API_KEY
HF_TOKEN
MATHPIX_APP_KEY
KAGGLE_API_TOKEN
# ... add new entries here
)
_keychain_get() {
local val
val="$(security find-generic-password -a "$USER" -s "$1" -w 2>/dev/null)" || {
echo "[secrets] Missing keychain item: $1, add with: _keychain_set $1" >&2
return 1
}
echo "$val"
}
When an AI agent reads this file, it sees an array of names and a function definition. It never sees the raw credential.
Storing Secrets
Security models fail when they introduce too much friction. If storing a key requires five manual steps, engineers will revert to hardcoding. The _keychain_set helper makes the secure path the easiest path:
# Also in ~/.zshenv
_keychain_set() {
if security find-generic-password -a "$USER" -s "$1" > /dev/null 2>&1; then
echo "WARNING: Keychain item '$1' already exists. This will overwrite it permanently (no undo)."
read -q "?Overwrite? [y/N] " || { echo ""; return 1; }
echo ""
security delete-generic-password -a "$USER" -s "$1" > /dev/null 2>&1
fi
local secret="${2:-}"
if [[ -z "$secret" ]]; then
read -s "secret?Enter value for $1: "
echo ""
fi
security add-generic-password -a "$USER" -s "$1" -w "$secret"
}
Adding a new secret is exactly two steps:
- Run
_keychain_set BRAVE_API_KEY(prompts for the value securely). - Add
BRAVE_API_KEYto the_SECRETSarray in~/.zshenv.
The read -s flag is critical. It suppresses terminal echo, so the plaintext value never appears on screen, in shell history (.zsh_history), or in ps output.
Loading at Startup
The .zshrc loading block is three lines:
# ~/.zshrc - resolve secrets at interactive shell startup
for _key in "${_SECRETS[@]}"; do
export "$_key"="$(_keychain_get "$_key")"
done
That is the entire runtime. On macOS, the login keychain is automatically unlocked when you log into the GUI session. No password prompt, no biometric input, no delay beyond the Keychain I/O.
The Cost
A hardcoded .zshrc adds effectively zero latency to shell startup. Here is the benchmark for loading 15 keys dynamically:
| Approach | Startup Time (15 keys) | Authentication |
|---|---|---|
Hardcoded .zshrc | ~0.0s | None |
| macOS Keychain | ~0.9s | Auto-unlocked on GUI login |
Under one second of added latency to remove credentials from AI agent context windows.
Portability
The Keychain integration is macOS-specific, but the pattern is pure POSIX shell. On Linux, swap _keychain_get for pass (GPG-encrypted), gnome-keyring, or secret-tool (libsecret). The _SECRETS array, the .zshenv/.zshrc split, and the startup loop work identically across any Unix system.
What Comes Next
This setup eliminates plaintext secrets from your dotfiles on a single machine. Two real-world scenarios remain:
Multi-machine synchronization. If you manage secrets across multiple workstations, the Keychain backend requires manual provisioning on each one. To solve that, we have successfully used 1Password CLI as a cloud-synced backend that resolves all keys in a single authenticated call with Touch ID (Part 2).
SSH access. The moment you SSH into your workstation, macOS deliberately restricts Keychain access and blocks biometric prompts. The solution that worked for us was combining 1Password Service Accounts with a session-aware loading strategy that bypasses biometrics entirely on remote connections (Part 3).