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 or 1Password CLI backends.
The rapid adoption of AI coding agents from Claude, Gemini, Codex, and others has changed the threat model of the developer environment. When we grant these agents terminal access, we are implicitly allowing them to read our shell configuration files. The .zshrc, .zshenv, .zprofile, .bashrc, or .profile are no longer just local setup scripts, they are part of the context window.
If your engineering team’s API keys, database passwords, and infrastructure tokens are hardcoded in those files, they are now part of the prompt.
This is not a theoretical risk, it is the default state of most developer environments today. A quick audit of any engineer’s dotfiles will likely surface sensitive credentials sitting in plain text: exported as environment variables, committed to dotfiles repositories, and synced across machines.
The traditional argument for hardcoding keys in shell configurations was convenience. They are always available, they survive restarts, and they require zero setup. That argument no longer holds when your shell config is routinely ingested by third-party AI systems. As engineering leaders, we need an architecture that removes plaintext secrets from the environment without destroying developer velocity.
1. The Exposure Surface
Here is what a typical .zshrc or .zshenv looks like in the wild:
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"
This file is:
- Read by every AI coding agent that has terminal access.
- Committed to git if the engineer uses a dotfiles repository.
- Synced to multiple machines manually or via backup tools.
- Visible in process memory to any tool that reads environment variables.
The exposure surface is large, and it grows with every new AI tool integrated into the workflow.
2. The Solution: Zero-Trust Shell Config
The fix is straightforward in principle: your shell config should reference keys, not contain them. The actual values must live in a secure store like macOS Keychain, 1Password, or an equivalent key manager, and be resolved only at interactive shell startup.
On macOS, two backends are immediately available for this:
- macOS Keychain: The built-in store. Fast, requires no external dependencies, and integrates tightly with the OS login system. However, it lacks version history and sync capabilities.
- 1Password CLI (
op): A robust, cloud-synced password manager with a CLI interface. It maintains version history, authenticates via Touch ID, and synchronizes easily across a developer’s fleet of machines.
The Architectural Split: .zshenv vs .zshrc
During our internal implementation, we learned a critical lesson about timing. The .zshenv file runs for every shell invocation, including non-interactive subshells, script executions, and IDE background processes. It runs far too early for biometric prompts or Keychain lookups to work reliably. When we initially placed our loading logic there, Touch ID dialogs appeared unpredictably, and Keychain access failed silently in non-interactive contexts.
The solution is a clean architectural split:
.zshenvis purely declarative. It defines the_SECRETSarray, helper functions, and the chosen backend config. No values, no I/O, no auth prompts..zshrcis the executor. It runs only for interactive shells, where biometric prompts and password dialogs are appropriate. This is where the actual loading and injection occurs.
Figure 1: The split architecture: .zshenv defines names and functions, while .zshrc resolves values from the appropriate backend based on the session type.
# ~/.zshenv -- helper functions and key names (no values)
export SECRET_BACKEND="keychain" # or "op"
# 1Password config (create vault using: op vault create CLI --account my.1password.com)
export OP_ACCOUNT="my.1password.com" # or "yourcompany.1password.com"
export OP_VAULT="CLI"
_SECRETS=(
BRAVE_API_KEY
CONTEXT7_API_KEY
ELEVENLABS_API_KEY
HF_TOKEN
MATHPIX_APP_KEY
KAGGLE_API_TOKEN
# ... add new entries here
)
# --- macOS Keychain ---
_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"
}
# --- 1Password CLI ---
_op_get() {
local val
val="$(op item get "$1" --vault "$OP_VAULT" --fields password --reveal 2>/dev/null)" || {
echo "[secrets] Missing 1Password item: $1 in vault '$OP_VAULT', add with: _op_set $1" >&2
return 1
}
echo "$val"
}
# --- Unified interface ---
_secret_get() {
case "$SECRET_BACKEND" in
op) _op_get "$1" ;;
keychain) _keychain_get "$1" ;;
esac
}
When an AI agent reads this file, it sees function definitions and an array of names. It never sees the raw credential.
3. The Path of Least Resistance
Security models fail when they introduce too much friction. If storing a key requires five manual steps, engineers will revert to hardcoding. The helper functions must make the secure path the easiest path.
_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"
}
_op_set() {
local secret="${2:-}"
if [[ -z "$secret" ]]; then
read -s "secret?Enter value for $1: "
echo ""
fi
if op item get "$1" --vault "$OP_VAULT" > /dev/null 2>&1; then
op item edit "$1" --vault "$OP_VAULT" "password=$secret"
else
op item create --vault "$OP_VAULT" --category Password --title "$1" "password=$secret"
fi
}
# Unified setter (uses active backend)
_secret_set() {
case "$SECRET_BACKEND" in
op) _op_set "$1" "$2" ;;
keychain) _keychain_set "$1" "$2" ;;
esac
}
Adding a new key is now exactly two steps:
- Run
_secret_set NEW_API_KEY(which prompts for the value securely). - Add
NEW_API_KEYto the_SECRETSarray in~/.zshenv.
Critically, we use read -s for the prompt. This suppresses terminal echo, ensuring the plaintext value never appears on screen, in the shell history (.zsh_history), or in ps output. The optional second parameter even allows piping for bulk, non-interactive migrations (e.g. _keychain_set BRAVE_API_KEY $(_op_get BRAVE_API_KEY)).
4. Performance at Scale: The op inject Paradigm
For 1Password users, a naive implementation loops through _SECRETS and calls op item get for each key. If an engineer has 15 secrets, this triggers 15 separate CLI invocations, each requiring a heavy round-trip to the 1Password app. In our testing, this made shell startup painfully slow.
The solution is op inject. It accepts a template on stdin with op:// references and resolves all of them in a single, highly optimized, authenticated call.
# ~/.zshrc -- resolve values at interactive shell startup
if [[ "$SECRET_BACKEND" == "op" ]]; then
_tpl=""
for _key in "${_SECRETS[@]}"; do
_tpl+="export ${_key}=\"op://${OP_VAULT}/${_key}/password\""$'\n'
done
eval "$(echo "$_tpl" | op inject)"
else
for _key in "${_SECRETS[@]}"; do
export "$_key"="$(_keychain_get "$_key")"
done
fi
This yields one Touch ID prompt, one process invocation, and instant resolution of all keys.
A technical nuance to remember: op whoami does not trigger Touch ID, it only checks the existing authentication state. However, op vault get and op item get do trigger Touch ID. This distinction is vital if you are writing background detection logic and want to avoid spurious biometric prompts.
5. Benchmarking the Cost of Security
Moving away from a hardcoded baseline introduces unavoidable I/O and cryptographic latency. A hardcoded .zshenv adds roughly ~0.0s to shell startup. Here is the benchmark for injecting 15 keys dynamically:
| Backend | Startup Time (15 keys) | Authentication | Best For |
|---|---|---|---|
| Hardcoded Baseline | ~0.0s | None | (Deprecated) |
| macOS Keychain | ~0.9s | Auto-unlocked on GUI login | Single-machine setups |
1Password (op inject) | ~1.5s | Touch ID per session | Multi-machine sync |
While ~0.9s or ~1.5s of latency is noticeable, it is a negligible price to pay to definitively remove the risk of credential leakage into LLM context windows.
Part 2: The SSH Complexity
Part 1 is elegant and takes an engineer about 20 minutes to set up. It works flawlessly for local GUI terminal sessions. But the moment you SSH into your workstation remotely, the architecture encounters the deliberate security boundaries of macOS:
- Biometric Blocking: 1Password Touch ID opens a biometric dialog on the machine’s physical display. Over SSH, you cannot tap it. The shell blocks indefinitely.
- Keychain Isolation: macOS Keychain unlock state is per-login-session. Unlocking the keychain from a local GUI terminal does not carry over to SSH sessions. There is no PAM module, no
sshd_configoption, and nolaunchdmechanism to auto-unlock the Keychain on SSH login. - Password Prompts: Over SSH,
security find-generic-passwordfails silently unless the keychain is explicitly unlocked.
These are not bugs, they are intentional, highly effective security constraints designed to prevent remote exfiltration. But they break automated startup workflows.
The Solution: 1Password Service Accounts
The most robust solution for remote access is leveraging 1Password Service Accounts. These use token-based authentication that requires no biometric input and no interactive password, providing read-only access to a specific vault.
The strategy is to provision the token locally and store it in a highly restricted file:
# One-time setup per machine (from local GUI terminal):
# 1. Sign in to 1Password.com and click "Developer Tools" > "Service Accounts".
# 2. Click "Create a service account" and name it (e.g., "CLI SSH Access").
# 3. Grant it "Read" access to your "CLI" vault.
# 4. Copy the generated token, then run the snippet below:
mkdir -p ~/.config/op
read -s "token?Enter 1Password Service Account Token: "
echo
echo "$token" > ~/.config/op/.sa-token
chmod 400 ~/.config/op/.sa-token
unset token
Then, in .zshrc, we detect the SSH connection and inject the secrets using this token:
# ~/.zshrc -- The final hybrid loading logic
if [[ -n "$SSH_CONNECTION" ]]; then
if [[ -r ~/.config/op/.sa-token ]]; then
export OP_SERVICE_ACCOUNT_TOKEN="$(cat ~/.config/op/.sa-token)"
_tpl=""
for _key in "${_SECRETS[@]}"; do
_tpl+="export ${_key}=\"op://${OP_VAULT}/${_key}/password\""$'\n'
done
eval "$(echo "$_tpl" | op inject)" 2>/dev/null || _SECRETS=()
else
# Fallback: unlock login Keychain (prompts for password once)
if security unlock-keychain ~/Library/Keychains/login.keychain-db 2>/dev/null; then
for _key in "${_SECRETS[@]}"; do
export "$_key"="$(_keychain_get "$_key")"
done
fi
_SECRETS=()
fi
elif [[ "$SECRET_BACKEND" == "op" ]]; then
# Local + 1Password
_tpl=""
for _key in "${_SECRETS[@]}"; do
_tpl+="export ${_key}=\"op://${OP_VAULT}/${_key}/password\""$'\n'
done
eval "$(echo "$_tpl" | op inject)"
else
# Local + Keychain
for _key in "${_SECRETS[@]}"; do
export "$_key"="$(_keychain_get "$_key")"
done
fi
The resulting matrix is robust:
- Local + Keychain: Loops
_SECRETS, fast ~0.9s load, zero prompts. - Local + 1Password: Single
op inject, one Touch ID prompt per session, ~1.5s load. - SSH + Service Account: Uses the
chmod 400token, bypasses biometrics entirely, fast load, zero prompts. - SSH + Keychain (Fallback): Prompts for the user’s macOS password once per SSH login, then loads normally.
6. Migration Checklist
For teams looking to roll this out, the migration takes roughly 30 minutes per engineer:
Phase 1: Inventory & Triage
grep -s "^export.*=.*['\"]" ~/.zsh* ~/.bash* 2>/dev/null- Identify all hardcoded variables.- Differentiate between actual secrets (API keys, tokens) and basic configuration (paths, local DB DSNs, feature flags). Only secrets need to be migrated.
Phase 2: Provisioning
- Select a backend:
keychainfor isolated machines,opfor multi-machine synchronization. - Run
_secret_set KEY_NAMEfor each secret. - Verify ingestion with
_secret_get KEY_NAME.
Phase 3: Refactoring Configs
- Add the
_SECRETSarray and helper functions to~/.zshenv. - Add the loading logic block to
~/.zshrc. - Delete all hardcoded
export KEY="value"lines.
Phase 4: Validation & Cleanup
- Open a new terminal pane and verify
printenv KEYworks. - Scrub historical dotfiles backups. If the dotfiles were ever stored in a public repository, you must rotate the exposed keys.
7. What About Linux?
While the underlying backends described here are native to macOS, the architectural pattern (splitting definitions into .zshenv and resolving them dynamically in .zshrc via an array) is pure POSIX-compliant shell logic. It is entirely portable.
On Linux, the _keychain_get helper is easily swapped for pass (GPG-encrypted), gnome-keyring, or secret-tool (libsecret). 1Password’s CLI (op) works identically on Linux, making it the most pragmatic, unified choice for engineering teams that span both operating systems.
The End of Plaintext
The era of hardcoded API keys in shell configuration files is over. While it was never a best practice, the risk was previously confined to a laptop being stolen. Today, with the proliferation of AI coding assistants, those files are actively, continuously read by software that relies on massive context windows and external network access.
The architectural shift requires under 100 lines of shell logic. It maintains exactly the same developer experience. printenv works, scripts execute flawlessly, and AI agents have the necessary environment variables available in runtime memory, but the structural vulnerability is eliminated. Your credentials remain in a secure store, completely invisible to the code assistants operating in your terminal.
That is worth 100 lines of shell.