Find the API Keys in Your ~/.zsh_history With gitleaks, Then Stop Saving Them
Book: System Design Pocket Guide: Fundamentals The series: Pocket Guides for Developers — 8 short books on agents, RAG, prompts, observability, events, databases and system design — the whole series My project: Herme
- Book: System Design Pocket Guide: Fundamentals
- The series: Pocket Guides for Developers — 8 short books on agents, RAG, prompts, observability, events, databases and system design — the whole series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You tested a new key one afternoon. export OPENAI_API_KEY=sk-..., a quick curl with an Authorization: Bearer header, maybe a mysql -u root -p with the password glued to the flag. The test passed and you moved on. The shell did not. Unless your history size has rolled it off, that line is still in ~/.zsh_history or ~/.bash_history, in plain text, in your home directory.
Then look at what else can read that directory. Claude Code's sandboxing docs say that, with the sandbox on, shell commands get "read access to the entire computer, except certain denied directories," and that "this default still allows reading credential files." That is a design choice you can change, and step 5 shows how. It also tells you what an agent running as your user can reach by default.
The bad version of this already happened. In August 2025, malicious versions of the Nx build package turned locally installed AI CLIs against their owners, per Wiz and Snyk. The payload ran Claude Code, Gemini CLI and Amazon's q with their permission-skipping flags (--dangerously-skip-permissions, --yolo, --trust-all-tools) and told them to search the disk for wallets, keystores, .env files and SSH keys. Neither write-up says the payload went after shell history. A file full of export lines is the same kind of target, though, and it takes a few commands to clean.
Step 1: scan the history files with gitleaks
gitleaks is an open-source secret scanner. Its dir command, per the README, scans "directories or files," so you can point it at a single history file. On macOS:
brew install gitleaks jq
gitleaks dir --redact -v ~/.zsh_history
gitleaks dir --redact -v ~/.bash_history
jq is for step 2. On Linux, the gitleaks README also lists Docker and Go as install routes. -v prints each finding with its rule and line number, and --redact masks the secret itself in that output, so the scan doesn't put your keys back on screen. The exit code is 0 when nothing is found and 1 when there are leaks or an error.
The gitleaks README now says it is "feature complete" and that "future releases will be security patches only." The latest release on GitHub is v8.30.1, from March 21, 2026. Its author, Zach Rice, has started a successor called Betterleaks, MIT-licensed, according to BleepingComputer. The commands here are for gitleaks. If you switch, check the Betterleaks docs for the equivalent.
gitleaks matches known token formats and high-entropy strings next to keywords. A short database password in mysql -pS3cret may not trip any rule. Count the shapes you know you typed, without printing them:
grep -acE 'mysql .*-p[^[:space:]]' ~/.zsh_history
grep -acE '://[^/:@ ]+:[^/@ ]+@' ~/.zsh_history
The second pattern counts URLs with a password in them, like postgres://app:hunter2@db/app. A non-zero count is a line you need to deal with in step 2.

Shell history is a plain-text file. Every key you typed is a line in it.
Step 2: rotate first, then scrub
Rotate every key gitleaks found before you touch the file. Deleting the line does not un-leak a key that has sat in plain text for months. Backups, synced dotfiles and anything that already read the file keep their copy. Issue a new key at the provider, revoke the old one, and only then clean up.
Order matters for the scrub, because a running shell holds its history in memory and can write it back when it exits. Close every other terminal first. In the one you keep, stop it from saving:
unset HISTFILE
zsh's docs say that if HISTFILE is unset, "the history is not saved," and bash's manual says the same for bash. Now write a full report to a temporary folder. Leave out -v so the findings go to the file, not your screen:
tmp=$(mktemp -d)
gitleaks dir --no-banner \
--report-format json \
--report-path "$tmp/report.json" \
~/.zsh_history
jq length "$tmp/report.json"
Each finding in the JSON report has a Secret field. Turn those into a list of fixed strings and drop every history line that contains one:
jq -r '.[].Secret' "$tmp/report.json" \
| grep -v '^$' | sort -u > "$tmp/secrets"
grep -avF -f "$tmp/secrets" ~/.zsh_history > "$tmp/clean"
wc -l ~/.zsh_history "$tmp/clean"
-F treats each secret as a literal string, -v keeps the lines that don't match, and -a stops grep from treating the file as binary if zsh stored any non-ASCII bytes. The grep -v '^$' matters: an empty pattern would match every line and leave you with an empty history. Matching on the secret, not the line number, also catches the same key pasted on several lines.
If the line counts dropped by roughly the number of findings, write the clean copy back and remove the temp folder:
cat "$tmp/clean" > ~/.zsh_history
rm -rf "$tmp"
cat > writes into the existing file, so it keeps its permissions. Handle the lines from your grep -c counts in an editor, repeat for ~/.bash_history, then close this terminal and open a new one. Run the step 1 scan again. Exit code 0 means it found nothing.
Step 3: stop new secrets from landing
zsh gives you two tools. HIST_IGNORE_SPACE drops any command that starts with a space, and the zshaddhistory hook runs on every line before it is saved. The zsh manual says that if the hook returns 2, "the history line will be saved on the internal history list, but not written to the history file." You keep up-arrow in the current shell, and the file never sees the line.
# ~/.zshrc
setopt HIST_IGNORE_SPACE
_secret_re='(KEY|TOKEN|SECRET|PASSWORD|PASSWD)[A-Z_]*='
_secret_re+='|[Aa]uthorization: *(Bearer|Basic) [^$]'
_secret_re+='|mysql .*-p[^[:space:]]'
_secret_re+='|://[^/:@ ]+:[^/@ ]+@'
zshaddhistory() {
emulate -L zsh
[[ ${1%%$'\n'} =~ $_secret_re ]] && return 2
return 0
}
The hook gets the full line with its trailing newline, which ${1%%$'\n'} strips, the same way the manual's example does. The [^$] after Bearer lets Bearer $OPENAI_API_KEY through, since a variable name is not a secret. The patterns are broad on purpose. A false positive costs you one line of history. Per the options docs, a line dropped by HIST_IGNORE_SPACE "lingers in the internal history until the next command is entered," so you can still fix a typo in it.
To test it, open a new terminal, type export DEMO_TOKEN=abc, close that terminal so zsh writes its history, then open another one and run grep -c DEMO_TOKEN ~/.zsh_history. If the hook works, the count is 0.

The zshaddhistory hook sees every line before the history file does.
bash has no hook with a "keep in memory only" status, but it has two variables. From the bash manual: with ignorespace in HISTCONTROL, "lines which begin with a space character are not saved," and HISTIGNORE is a colon-separated list of patterns, each "anchored at the beginning of the line" and required to "match the complete line." Hence the leading and trailing *:
# ~/.bashrc
HISTCONTROL=ignoreboth
HISTIGNORE='*KEY=*:*TOKEN=*:*SECRET*=*:*PASSWORD=*'
HISTIGNORE+=':*uthorization?*Bearer [!$]*:*mysql *-p?*'
ignoreboth includes ignorespace. A pattern can't hold a literal colon, since the colon separates patterns, so ?* matches the one after Authorization. The [!$] lets Bearer $OPENAI_API_KEY through, as in the zsh version. A matching line never enters bash's history at all, not even for up-arrow.
Step 4: read keys from the keychain, not from your fingers
The hook catches mistakes. The real fix is never typing the literal. Store the key once in the OS keychain. On macOS, the security man page says to put -w "at end of command to be prompted (recommended)", so the key goes into a prompt and the command line stays clean:
security add-generic-password -a "$USER" -s openai -w
On Linux with a Secret Service keyring, secret-tool prompts the same way: secret-tool store --label='OpenAI key' service openai. Then read it back by name:
# ~/.zshrc
openai_key() {
security find-generic-password -a "$USER" -s openai -w
# Linux: secret-tool lookup service openai
}
with-openai() {
OPENAI_API_KEY="$(openai_key)" "$@"
}
Now history holds with-openai python3 summarize.py, and a one-off request looks like this:
curl https://api.openai.com/v1/models \
-H "Authorization: Bearer $(openai_key)"
The shell saves $(openai_key) as text and only expands it when it runs. The key still lives in that process's environment while it runs. What this removes is the permanent copy in a file anyone with home-folder access can open.
Step 5: tell your agents the file is off limits
Claude Code lets you block the file twice, and you need both. Permission rules cover its built-in file tools, and ~/path rules are relative to your home directory, per the permissions docs. The sandbox's filesystem.denyRead covers shell commands. The sandboxing docs say plainly that "a denyRead entry doesn't stop the Read tool." In ~/.claude/settings.json:
{
"permissions": {
"deny": [
"Read(~/.zsh_history)",
"Read(~/.bash_history)"
]
},
"sandbox": {
"filesystem": {
"denyRead": ["~/.zsh_history", "~/.bash_history"]
}
}
}
The sandbox rule applies when the sandbox is on (/sandbox in a session). For a wider net, the same docs describe permissions.blockReadsOutsideWorkingDirectories, which takes sandboxed commands' read access away from your home directory. Codex and the other agents have their own sandbox settings, so check each one you run.
What Hermes 2.3.2 changed, and what it left alone
I build Hermes IDE, so weigh this section with that in mind. Hermes offers ghost-text suggestions from your command history as you type, in shells without suggestions of their own. Hermes 2.3.2, released on October 13, 2026, lists two fixes for that. What you type at a hidden password prompt (sudo, ssh, a script asking for a passphrase) "is never remembered or suggested." Commands that hold a secret, such as setting an API key, passing a password, an auth header or a URL with a password in it, "are never shown as a suggestion or as faded text after the cursor."
The second fix ends with this: "Your shell's own history is not changed." That is deliberate. Hermes stops showing the secret back to you, but ~/.zsh_history belongs to your shell, and the key you exported last spring is still in it until you run steps 1 and 2. A plain shell in Hermes runs in a real terminal by default, so the ~/.zshrc hook from step 3 applies there like anywhere else. Hermes is free, source-available under BSL 1.1, and runs on macOS, Windows and Linux. It is not affiliated with Anthropic, OpenAI or any agent vendor named here.
If this was useful
Where a credential lives, and which process can read it, is a small version of a question every system answers: what each part holds, and what it is allowed to reach. The System Design Pocket Guide: Fundamentals covers the core building blocks you put together at system scale, so you can reason about those boundaries before an incident makes you. It is one of 8 short books on agents, RAG, prompts, observability, events, databases and system design in the Pocket Guides series. If you run several coding agents a day, Hermes IDE is free to download, and the code is on GitHub.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.
