Stop Pasting Your Secrets Into LLMs: Encrypt Your Shell Config

If you're like most engineers, your .zshrc or .bashrc is a graveyard of export statements: API keys, database URLs, OAuth client secrets, AWS access keys, GitHub PATs. They sit there in plaintext, getting sourced fresh into every shell, every tmux pane, every IDE terminal.

That was a livable risk back when the only thing reading those files was you. Now you have a coding agent in your editor that happily reads the entire workspace, and you have a habit of copy-pasting log output into a chat window when something breaks. The blast radius of "secrets in plaintext on disk" got a lot bigger the moment LLMs joined your workflow.

This post walks through a small, paranoid, practical setup: keep your shell secrets encrypted at rest with PGP, bind the decryption key to either a hardware-derived identifier or a locally-generated UUID, and have a script decrypt them automatically for every new shell session — without ever writing the plaintext back to disk.

The Problem: Plaintext .zshrc in an AI-Assisted World

A few specific risks have gotten worse:

  1. Editor agents reading your dotfiles. Your AI assistant doesn't always need to see your home directory, but it can. A misconfigured workspace, a careless prompt ("look at my zshrc and help me clean it up"), or a tool with broad filesystem access is one query away from your secrets.
  2. Copy-paste leakage. Errors in the terminal often include environment context. You paste a stack trace into Claude or ChatGPT to ask for help, and a Bearer eyJ... token rides along.
  3. Backup and sync exposure. Dotfiles in iCloud, Dropbox, Google Drive, or a public GitHub dotfiles repo are extremely common. Once plaintext is up there, you've delegated trust to whatever scans those backups.
  4. Shoulder surfing in screenshares. cat ~/.zshrc on a Zoom call ends careers.

The fix isn't to memorize every secret or to abandon shell exports. It's to keep the file at rest encrypted, decrypt it into memory only when a shell starts, and let the operating system clean up the plaintext when the shell exits.

The Setup, In Concept

We're going to:

  1. Move all the sensitive export lines out of .zshrc into a separate file: ~/.secrets.env.
  2. Encrypt that file with PGP into ~/.secrets.env.gpg.
  3. Use either a hardware-derived identifier (Mac serial number, machine ID) or a locally-generated UUID stored in a protected location as the passphrase for the PGP key.
  4. Add a few lines to .zshrc that source a decryption script. The script writes the plaintext to a tmpfs / in-memory location, sources it, and shreds it.

The threat model: an LLM, agent, or careless paste can never see the plaintext from the file at rest. Only a process running as you, on this machine, with access to the binding identifier, can decrypt the file. The plaintext only ever lives in shell memory and an ephemeral file that gets shredded immediately after sourcing.

This isn't unbreakable. A compromised local user account can still read the binding identifier and decrypt the file. But that's a much higher bar than "agent reads .zshrc."

Step 1: Generate the Binding Identifier

You have two options. Pick one.

Option A: Hardware-derived identifier (macOS)

This ties decryption to this physical machine. If your encrypted secrets file gets copied to another computer, it cannot be decrypted there.

# macOS — pull the hardware UUID from system_profiler
HARDWARE_ID=$(system_profiler SPHardwareDataType \
  | awk '/Hardware UUID/ {print $3}')
echo "$HARDWARE_ID"

On Linux, the equivalent is usually cat /etc/machine-id or the contents of /sys/class/dmi/id/product_uuid (root-only on most distros).

Option B: Locally-generated UUID

This ties decryption to a small file on this machine. The advantage: you control where it lives, you can rotate it, and you can put it on an encrypted volume separate from the rest of your home directory.

mkdir -p ~/.config/shell-secrets
chmod 700 ~/.config/shell-secrets
uuidgen > ~/.config/shell-secrets/binding.key
chmod 400 ~/.config/shell-secrets/binding.key

The chmod 400 makes it read-only and owner-only — anything else trying to read it will get permission denied without elevated privileges.

Either way, the resulting string is what we'll feed into PGP as the passphrase.

Step 2: Move Secrets Out of .zshrc

Create a new file, ~/.secrets.env, and move every sensitive export into it:

# ~/.secrets.env
export OPENAI_API_KEY="sk-..."
export ANTHROPIC_API_KEY="sk-ant-..."
export GITHUB_TOKEN="ghp_..."
export AWS_ACCESS_KEY_ID="AKIA..."
export AWS_SECRET_ACCESS_KEY="..."
export DATABASE_URL="postgres://user:pass@host/db"

Anything non-sensitive (path tweaks, aliases, prompt config) stays in .zshrc. The split matters — your .zshrc should remain safe to share or screenshot.

Step 3: Encrypt the File

Make sure GnuPG is installed (brew install gnupg on macOS, package manager elsewhere). Then encrypt symmetrically using your binding identifier as the passphrase:

# Pick whichever binding identifier you used in Step 1
BINDING=$(system_profiler SPHardwareDataType | awk '/Hardware UUID/ {print $3}')
# OR: BINDING=$(cat ~/.config/shell-secrets/binding.key)

gpg --batch --yes --passphrase "$BINDING" \
    --symmetric --cipher-algo AES256 \
    --output ~/.secrets.env.gpg \
    ~/.secrets.env

Now verify decryption works before you delete the plaintext:

gpg --batch --yes --passphrase "$BINDING" \
    --decrypt ~/.secrets.env.gpg

You should see your exports printed back. If that worked, shred the plaintext:

# macOS doesn't ship `shred`; use `rm -P` instead, or install coreutils
rm -P ~/.secrets.env
# or on Linux:
shred -u ~/.secrets.env

From now on, the only copy on disk is the .gpg file, which is useless without the binding identifier.

Step 4: The Decryption Helper Script

Save this as ~/.config/shell-secrets/load.sh:

#!/usr/bin/env bash
# load.sh — decrypt shell secrets into the current shell, leave nothing on disk.

set -euo pipefail

ENCRYPTED="$HOME/.secrets.env.gpg"

# Pick whichever binding source you used. This example uses macOS hardware UUID.
get_binding() {
  if [[ "$(uname)" == "Darwin" ]]; then
    system_profiler SPHardwareDataType | awk '/Hardware UUID/ {print $3}'
  elif [[ -r "$HOME/.config/shell-secrets/binding.key" ]]; then
    cat "$HOME/.config/shell-secrets/binding.key"
  else
    echo "No binding identifier available." >&2
    return 1
  fi
}

# Decrypt to stdout — never to a file. The shell `eval`s the output in-process.
BINDING="$(get_binding)"
PLAINTEXT="$(gpg --batch --quiet --passphrase "$BINDING" --decrypt "$ENCRYPTED")"

# Source the exports into the current shell. Using process substitution would
# write to /dev/fd, which is fine, but `eval` keeps it purely in memory.
eval "$PLAINTEXT"

# Wipe the variable. This won't fully prevent a determined attacker with
# memory access, but it shortens the window the plaintext exists.
unset PLAINTEXT BINDING

Make it executable and lock it down:

chmod 700 ~/.config/shell-secrets/load.sh

The key design decisions in this script:

  • No tmpfs file. Earlier drafts of this pattern used a tmpfs / /dev/shm file as an intermediate; that's a window of plaintext on disk (or in shared memory) and a chance for another process to read it. eval against the captured stdout keeps the secrets purely in this shell's variable space.
  • set -euo pipefail so any failure in the chain stops execution loudly rather than silently leaving you with no secrets.
  • Explicit unset of the plaintext variable when we're done. Not a guarantee, but reduces the window meaningfully.

Step 5: Wire It Into .zshrc

Add a single line near the bottom of ~/.zshrc:

# Decrypt shell secrets for this session.
[[ -f "$HOME/.config/shell-secrets/load.sh" ]] && source "$HOME/.config/shell-secrets/load.sh"

Open a new terminal. You should have all your env vars set, with no plaintext anywhere on disk:

echo $OPENAI_API_KEY  # should print your key
ls -la ~/.secrets.env  # should fail — file doesn't exist

That's the whole loop.

Editing Secrets Later

When you need to add or rotate a key, the workflow is:

BINDING=$(system_profiler SPHardwareDataType | awk '/Hardware UUID/ {print $3}')

# Decrypt to stdout, edit in your editor of choice, encrypt again
gpg --batch --quiet --passphrase "$BINDING" --decrypt ~/.secrets.env.gpg \
  | $EDITOR /dev/stdin

# That doesn't quite work for most editors — the cleaner pattern is:
gpg --batch --quiet --passphrase "$BINDING" --decrypt ~/.secrets.env.gpg > /tmp/secrets.tmp
$EDITOR /tmp/secrets.tmp
gpg --batch --yes --passphrase "$BINDING" --symmetric --cipher-algo AES256 \
    --output ~/.secrets.env.gpg /tmp/secrets.tmp
rm -P /tmp/secrets.tmp

Even better, wrap that in a function called edit-secrets and put it in your .zshrc. The plaintext lives in /tmp for the duration of the edit and gets shredded as soon as you save.

What This Doesn't Protect Against

Be honest about the threat model:

  • Local code execution as your user. Anything running as you can read the binding identifier and decrypt the file. This is a defense against passive exposure (LLM workspace reads, accidental commits, screenshares), not against active malware.
  • Memory dumps. Once the secrets are loaded into the shell, they live in process memory until the shell exits. A debugger attached to the shell can see them.
  • The agent reading the live shell environment. If your AI tool has shell tool-use enabled and it runs env, it sees everything. The fix there is a separate one: don't grant env or shell-execution permission to agents that don't need it, and audit which tools your agent has.

But for the most common modern leak — plaintext secrets sitting in a file an LLM might read — this closes the gap cleanly.

Why This Beats .env Files in Projects

A common pattern is to keep secrets in per-project .env files and rely on .gitignore. That works for not committing them, but it doesn't protect against:

  • Editor agents indexing your filesystem.
  • A misconfigured CI runner uploading your workspace.
  • You accidentally including the file in a tar you ship somewhere.

Encrypting your shell config doesn't replace .env discipline in projects, but it removes the long-lived, always-readable plaintext from your home directory — which is where the AI tools tend to be looking.

Final Note: Be a Boring Adult About This

There's nothing fancy here. PGP has been around for thirty years. Hardware UUIDs and machine IDs are documented OS features. The script is twenty lines of bash. The whole pattern took an afternoon to set up the first time and now runs invisibly forever.

The interesting question isn't "is this novel" — it's "why isn't this the default?" Most of us have been running plaintext exports for so long that we stopped noticing the pile of API keys in our home directory. AI tooling didn't create the problem; it just made the cost of the existing problem high enough to be worth fixing.

Encrypt the file. Bind the key to the machine. Decrypt on shell startup. Stop worrying about which tool got to see your dotfiles today.