- Apiiro counted 2,539 real secrets in 1,994 public GitHub repositories, most of them in
.claude/settings.local.json, the file Claude Code writes when you pick "Yes, and don't ask again". I reproduced the mechanism on 2.1.319. The rule it saves is the full command string,PGPASSWORD=and bearer header included, with no prefix and no redaction. - Claude Code keeps that file out of git by adding one line to the global excludes file in your home directory, not to the repository. In a clone on a machine without that line,
git add -Astages it. Once the file is tracked, the exclude does nothing. - When the file is already committed, Claude Code notices. It prints that it is ignoring the file's allow rules until you trust the workspace. Then it appends the next approved command, token included, to the same tracked file.
Apiiro's research went up last week and was updated on 8 November. The numbers are large. Public GitHub holds 104,185 copies of .claude/settings.local.json across 103,617 repositories, and 1,122 of them carry a real secret: 379 database passwords, 122 Supabase service-role keys, 43 SSH logins, 23 AWS keys. Add git history, the shared settings.json, and saved agent transcripts, and the total reaches 2,539 distinct secrets in 1,994 repos. Sixty-three organisations are affected, and one confirmed its key had been live.
Boris Cherny, who runs Claude Code at Anthropic, replied on Hacker News on 7 November. He said the team has "historically" not done secret scrubbing and is "looking into what we can do to prevent this". He also said Claude Code gitignores the file when it creates it, so in his view the exposed copies were created by hand and checked in. I ran 2.1.319 against a throwaway repository to see which of those claims hold.
What the approval writes
I drove claude -p over its stream-json channel with my own script answering the permission prompts. That is the channel the Agent SDK uses. Every prompt arrives with a permission_suggestions block: the rule Claude Code offers behind "Yes, and don't ask again". I asked Haiku to run three commands against a closed port on localhost, each with a fake credential in it.
The first was a Postgres check with the password in the environment: PGPASSWORD=NotARealPassword123 psql -h 127.0.0.1 -p 1 -U app -d appdb -c 'select 1'. The suggestion came back as one rule:
{"type": "addRules",
"rules": [{"toolName": "Bash",
"ruleContent": "PGPASSWORD=NotARealPassword123 psql -h 127.0.0.1 -p 1 -U app -d appdb -c 'select 1'"}],
"behavior": "allow", "destination": "localSettings"}
The second put the password in a connection string, postgresql://app:AlsoFakePass456@127.0.0.1:1/appdb. The third was a curl with Authorization: Bearer sk-fake-token-789. Same shape every time: the whole command, verbatim, destined for local settings. After I accepted all three, the file in the repo read:
"allow": [
"Bash(PGPASSWORD=NotARealPassword123 psql -h 127.0.0.1 -p 1 -U app -d appdb -c 'select 1')",
"Bash(psql postgresql://app:AlsoFakePass456@127.0.0.1:1/appdb -c 'select 1')",
"Bash(curl -s -H 'Authorization: Bearer sk-fake-token-789' http://127.0.0.1:1/health)"
]
It is not the password that stops Claude Code from generalising. A fourth run with the same psql command and no password at all also got the full string as its rule. The docs' example offers npm test *, a prefix. The psql and curl invocations I tried were saved whole, and the whole thing is where the secret sits.
The rule also works. A second session running the first command got no prompt at all. That is the feature doing its job, and it is the entire problem: the thing it remembers is the command, and the command carries the password.
The ignore rule lives in your home directory
The settings docs say Claude Code adds **/.claude/settings.local.json to your global git excludes file the first time it writes the file inside a git repository. On a Mac with no custom core.excludesFile, that is ~/.config/git/ignore. Mine has the line, and in the probe repository git status showed !! .claude/, ignored.
Then I ran the same git status with HOME pointed at an empty directory, which is what a teammate's laptop, a CI runner, or a fresh clone looks like. The output changed to ?? .claude/, untracked, and git add -A --dry-run answered add '.claude/settings.local.json'. Nothing in the repository marks the file as private. The one line that protects it stays on the machine where the approval happened.
That is the gap between Cherny's account and Apiiro's data. Both are right. Claude Code does keep the file out of git, on one machine, for files it created there. Apiiro traced 1,105 repositories with a real secret and found that in 539 of them a commit co-authored by Claude changed the file. Claude Code's own commit instructions, which I pulled out of the binary, tell the model: "Do not commit files that likely contain secrets (.env, credentials.json, etc)." Its own settings file is not on that list.
Once it is tracked, Claude Code knows, and keeps writing
I made a copy of the probe repo and force-added the settings file to a commit. With the global exclude still in place, git status came back clean and git check-ignore reported no rule, because an exclude pattern has no effect on a file git already tracks. That is the state of every one of Apiiro's 1,122 files.
Then I ran one more approval in that copy, a curl with a different bearer token. Claude Code's first reaction was on stderr:
"Ignoring 3 permissions.allow entries from .claude/settings.local.json: this workspace has not been trusted."
It had checked the index, seen the file was tracked, and done what the permissions docs describe: treated it as repository-supplied and held its rules until the folder is trusted. The message is about trust. It says nothing about the three passwords in the file it just read.
The same session then offered the same full-string rule, destination still local settings, and appended the new bearer token to the tracked file. git status showed M .claude/settings.local.json, and git add -A was ready to stage it. Inside the binary, the writer logs "is tracked in the index; gitignore rules do not apply to tracked files" at warning level, into the debug log.
Users had reported this before. Issue 5544, filed in August 2025, describes approved commands "stored as is with plain text credentials". A bot closed it in January after 60 days without activity.
Issue 11982, from November 2025, is about the file not being gitignored on creation, and was closed as stale in March. Issue 13106, filed in December by someone whose key had already been found in a public repo, was closed as a duplicate of 11982 four days later. Apiiro says none of them got a reply from Anthropic.
What to do this week
Open every .claude/settings.local.json on your machine and read the allow list. Each entry is a command you approved, with whatever was typed into it. Delete every rule that holds a secret, and rotate the secret first, because the rule has been readable since the day you approved it.
Then check your repositories:
git ls-files .claude/settings.local.json
git log --all --oneline -- .claude/settings.local.json
If either returns anything, untrack it with git rm --cached .claude/settings.local.json, add the path to the repository's own .gitignore, and treat every credential the file ever held as exposed. Deleting the current copy clears nothing from history, forks, or clones. Apiiro found 734 secrets that exist only in earlier versions of the file.
Put the path in your organisation's .gitignore templates, so the protection travels with the project instead of living in one developer's home directory. Add .claude/, .cursor/, and the rest of the agent folders to pre-commit and CI secret scanning, and check that the scanner catches passwords inside commands. Gitleaks flagged 564 of Apiiro's 1,122 files, TruffleHog 643, and neither flagged 215, mostly passwords after PGPASSWORD=, sshpass -p, or mysql -p that carry no provider prefix.
Change the habit that produces the rule. A command written as psql "$DATABASE_URL" -c 'select 1' gets saved as exactly that text, variable name and all. The rule then approves that text whatever the variable holds, which is a different tradeoff, but it is not a password on disk. Where you can, write the pattern yourself in /permissions, as Bash(psql *), instead of letting the dialog save the invocation.
Cherny said the team will prototype a few fixes, including having auto mode catch this and building in basic secret scanning, before shipping anything. Until that lands, the behaviour on 2.1.319 is the one above. The feature works as documented. The rule is the command, the command carries the password, the exclude stays on your laptop, and when the file does escape into the repo, the response is to stop trusting the rules and add another one.