Click "Yes, don't ask again" on a command in Claude Code and it writes an allow rule into .claude/settings.local.json, inside the project. Some prompts offer a pattern like npm test *; others save the exact command. When it's the exact command, and the command had a password in it, so does the file. The approval is stored as a copy of what you approved, and a copy of a secret is a secret.
Apiiro looked at public GitHub: 104,185 copies of that file in 103,617 repos, and 1,122 of them holding at least one secret. That's 1,379 distinct secrets, among them 379 database passwords, 43 SSH logins, 42 GitHub, GitLab or npm tokens and 23 AWS keys. When Apiiro checked, every one of those files was still on the default branch, and in half the affected repos it had been sitting there for at least 217 days.
The Mechanism
Two small decisions stack.
- The rule is the command. An approval needs something to match next time, and the simplest thing to match is the literal string.
PGPASSWORD=... psql -h db -U appbecomes a rule, value and all. As one write-up put it: "The permission remembers the command, and the command contained the secret." - The ignore is per-machine. Claude Code's settings docs say it adds
**/.claude/settings.local.jsonto your global git excludes file. Not the project's.gitignore. Git's own docs are clear on the difference: patterns that every clone should ignore go in.gitignore;core.excludesFileis for one user's own patterns. So the file is ignored on the laptop that created it, and nowhere else. Any machine or process without that exclude commits it with everything else. Per The Hacker News, in 539 of the 1,105 repos Apiiro could trace, the file was changed by a commit that credits Claude as co-author.
Together they put a credential in the working tree, with protection that doesn't travel with it. Apiiro reviewed 16 coding agents, and Claude Code was the only one confirmed to save the full approved command in a project file by default.
Why Your Scanner Missed It
Gitleaks flagged 564 of the 1,122 files and TruffleHog 643. 215 got flagged by neither. The scans ran without the tools' verification step, and my read of the gap is shape. Scanners look for things shaped like keys: an AKIA prefix, a ghp_ token. A password after PGPASSWORD=, sshpass -p or mysql -p is an ordinary word on a command line, and that's exactly what an approval file is full of.
The Check
Find the file in the checkout and in history:
The first line lists tracked copies anywhere in the tree. The second shows whether one was ever committed on any branch, even if it's gone now. The third is a rough candidate list for the shared settings.json as well: it will over-match, and that's fine for a file this small.
The Fix
- Rotate first. A secret that reached a pushed commit is burned, even if the file is gone from
main. Deleting it from history is cleanup, not remediation. - Ignore it in the repo, not on the laptop. Commit this to
.gitignore, then untrack what's there:
The **/ matters: a pattern with a slash in it is anchored to the .gitignore's directory, so the plain .claude/settings.local.json misses a copy in services/api/.claude/. Since v2.1.211 Claude Code writes the file at the repo root inside a git repo; older versions, and sessions outside git or on Windows, write it wherever you started, so nested copies exist.
- Keep secrets out of the command text. This was a bad habit long before agents: commands end up in shell history, and
sshpass -pormysql -pcan put the password inpstoo. Use~/.pgpass, SSH keys, or env from your secrets manager, and the command the agent learns has nothing worth stealing. - Teach your scanner the shapes. Add custom rules for
PGPASSWORD=,sshpass -pand-pfollowed directly by a value, scoped to.claude/.
If you approve a command with a secret in it, the way to keep the secret out of the rule is a wider rule: psql with any arguments, auto-approved. That's a bigger grant than the one you meant to give. Fixing the argv habit is what removes the choice.
The Class
This is the third time in a week I've written about the same object: a trust decision stored as a line in a file, agent config committed by a worm, and now an approval list that doubles as a credential store. Each time the agent's security state is a plain-text file in the same tree as the code, readable and writable by whatever touches the tree.
My bet: that doesn't get fixed one file at a time. Approvals, trust and secrets are primitives an agent environment needs, and they belong in a store built for them, not in a JSON file next to package.json. Until then, the .gitignore line is the part you own.