You typed a password into a shell command once, hit the option that said yes and do not ask again, and moved on. That approval is now a line of JSON at the root of your repository, with the command stored the way you typed it. If the repository is public, so is the password.
Apiiro published the measurement on November 6. Searching public GitHub, the researchers found 104,185 copies of .claude/settings.local.json across 103,617 repositories, and 1,122 of those copies, about 1.1 percent, carried at least one secret. That is 1,379 distinct secrets in the current files alone. Another 734 sit only in older commits across 509 repositories, 211 forks of 74 repositories carry the same material, and 298 of 76,011 copies of the shared .claude/settings.json hold secrets too. Count the agent transcripts that ship alongside them and the total reaches 2,539 secrets across 1,994 repositories. More than a third open a database directly: 379 database passwords, plus 43 SSH logins to public servers, 42 GitHub, GitLab and npm tokens, and 23 AWS keys. Every file with a secret in it was still on its default branch, and in half of those repositories it had been there at least 217 days.
Here is the relevance verdict. If nobody at your company runs a coding agent inside a git working tree, none of this is yours and you can close the tab. If your engineers do run one but pass credentials only through a file, a keychain or an environment the command itself does not contain, your allow lists hold command patterns and you have nothing to rotate. The exposure is specific: it belongs to anyone who has ever typed a credential as part of a command during an agent session and then granted that command a standing approval. The file name is Anthropic's, but the mechanism is not. Cursor, Copilot CLI and Gemini CLI all persist approvals to project-local config, and the same keystroke produces the same result.
What the allow rule actually stores
The behaviour is documented, not hidden. Claude Code's permissions reference states that when you choose "Yes, and don't ask again" and the approval saves permanently, which it does for Bash commands and WebFetch domains, the rule is written to .claude/settings.local.json at the root of the git repository, and applies to every later session anywhere in that repository.
What varies is the shape of the rule. The documentation describes two forms: a prefix pattern such as Bash(npm run *), which matches a family of commands, and an exact-match rule such as Bash(npm run build), which matches one command string and nothing else. The dialog offers the prefix form when the command has a prefix worth generalising. A one-off invocation does not, and the reference is explicit about the remedy: to approve a specific invocation with no wildcard in it, write an exact-match rule for the full command string.
PGPASSWORD=... psql -h db.internal -U reporting has no useful prefix. Neither does sshpass -p ... ssh deploy@198.51.100.20. So the thing that gets written down is the command, in full, with the credential in the middle of it. Here is the resulting file, with the values replaced:
{
"permissions": {
"allow": [
"Bash(npm run test:*)",
"Bash(git log *)",
"Bash(PGPASSWORD=Sp1ng-reporting-2025 psql -h db.internal -U reporting -c 'select count(*) from orders')",
"Bash(sshpass -p 'deploy!Winter24' ssh deploy@198.51.100.20 'systemctl restart api')",
"Bash(curl -H 'Authorization: Bearer ghp_EXAMPLEEXAMPLEEXAMPLEEXAMPLE' https://api.github.com/user)"
]
}
}
The first two lines are patterns. The last three are credentials in a config file, and they look exactly like the first two to anything that is not reading the strings.
Why your secret scanner walked past it
Apiiro ran the two obvious tools over the same 1,122 files. Gitleaks flagged 564. TruffleHog flagged 643. Neither flagged 215 of them. For a file class that is plain JSON sitting on a default branch, that is a wide miss, and the reason is structural rather than a bug in either tool.
Both scanners are built around credentials that have a shape. An AWS key starts AKIA and runs twenty characters. A GitHub token starts ghp_. A JWT has three base64 segments split by dots. Those rules fire with near-zero false positives because the token format carries the evidence. A Postgres password does not have a format. Sp1ng-reporting-2025 is a short, low-entropy string whose only tell is the eleven characters in front of it, and recognising PGPASSWORD= as a credential assignment means parsing the string as a shell command rather than as JSON.
TruffleHog's verification model widens the gap. Its strength is proving a finding live by calling the provider API, which works for a GitHub token and cannot work for a password on an internal database host. The scanner is not wrong to stay quiet; it has no way to confirm anything. That is why the fix is a handful of rules you write yourself, keyed on the command syntax instead of the token format:
# inline-creds.toml
title = "Inline credentials in approved shell commands"
[extend]
useDefault = true
[[rules]]
id = "inline-pgpassword"
description = "Postgres password assigned inline in a command"
regex = '''PGPASSWORD=['\"]?([^\s'\"]{6,})'''
keywords = ["pgpassword"]
tags = ["password", "cli", "agent-config"]
[[rules]]
id = "inline-sshpass"
description = "SSH password passed to sshpass -p"
regex = '''sshpass\s+-p\s*['\"]?([^\s'\"]{4,})'''
keywords = ["sshpass"]
tags = ["password", "cli", "agent-config"]
[[rules]]
id = "inline-mysql-password"
description = "MySQL password attached to the -p flag"
regex = '''mysql(?:dump)?\s[^\n]*?-p['\"]?([^\s'\"-][^\s'\"]{3,})'''
keywords = ["mysql"]
tags = ["password", "cli", "agent-config"]
# working tree, then every commit that ever existed
gitleaks dir . --config inline-creds.toml --report-format json --report-path wt.json
gitleaks git . --config inline-creds.toml --report-format json --report-path hist.json
Point the same config at .claude/, .cursor/, .gemini/ and your Copilot configuration directory. Agent config folders are new enough that most scanner invocations inherited a path list written before they existed.
Find out what you have already published
Three questions, in order: is the file tracked right now, was it ever tracked, and did anybody fork you while it was. The first is a one-liner across every repository on a workstation.
#!/usr/bin/env bash
# Sweep every git repo under $HOME for agent approval files, now and in history.
find "$HOME" -type d -name .git -prune 2>/dev/null | while read -r g; do
repo="$(dirname "$g")"
for f in .claude/settings.local.json .claude/settings.json \
.cursor/settings.json .gemini/settings.json; do
if git -C "$repo" ls-files --error-unmatch "$f" >/dev/null 2>&1; then
echo "TRACKED $repo/$f"
elif git -C "$repo" log --all --full-history --oneline -- "$f" | grep -q .; then
echo "HISTORY $repo/$f ($(git -C "$repo" log --all --oneline -- "$f" | wc -l) commits)"
fi
done
done
For the repositories that come back, read the versions rather than guessing at them. git log --all --full-history -p -- .claude/settings.local.json prints every state the file has ever held, which is the set you have to rotate, not just whatever is on the tip today.
Apiiro traced 1,105 of the affected repositories and found that in 539 of them the commit that changed the file listed the agent as a co-author. If your team's agent commits carry that trailer, git log --all --grep='Co-Authored-By' --name-only narrows a large monorepo to the commits worth reading. The forks are the part you cannot fix: 211 forks of 74 repositories carried the same secrets as their upstream, and removing an object from your repository does not remove it from a fork's object store, where it stays reachable by hash.
Rotate first, then clean up
The ordering matters more than any single step, and I see it inverted most of the time. Teams discover the file, feel the urge to make it disappear, rewrite history, force-push, and only then start thinking about the credential. That sequence publishes a signal that one specific secret was worth hiding while leaving the old blob reachable in forks, in pull-request refs and in whatever cached the page. Rotate while the exposure is still boring.
The order that works
- Inventory every version of the file across the working tree and the full history, per the sweep above. The list of secrets to rotate is the union, not the current file.
- Rotate every credential on that list, including the ones you believe are dead. Apiiro did not test whether the secrets still worked, and one notified company confirmed its key was live.
- Untrack and ignore:
git rm --cached .claude/settings.local.json, then add the path to the project's own.gitignore. Claude Code adds the file to your global git excludes on first write, which protects the machine that created it and does nothing for a teammate's clone or for a file that is already committed. - Purge the history only for repositories that were public, and only after rotation. Treat it as cleanup, not as remediation.
- Turn on push protection where your host offers it, knowing it catches the shaped tokens and not the inline passwords. The custom rules above belong in a pre-commit hook for the rest.
Three habits that stop the next one
The prompt is where this gets decided, and it tells you something if you read it. When the dialog offers to approve a pattern such as Bash(npm run test:*), the command generalised cleanly. When the only option on the table is the literal command string, the command contains something specific enough that it cannot be generalised, and more often than not that something is a secret. Treat the absence of a pattern as the warning it is, approve for the session, and fix the command.
Then keep the value out of the command entirely. Postgres reads ~/.pgpass or PGPASSFILE. SSH takes a key file. aws-vault exec hands short-lived credentials to a subprocess. The agent needs to run the command; it has no reason to see the secret, and a command that reads its credential from a file is also a command with a stable prefix, which means the approval that gets written down is a pattern.
Last, close the second leak path. The session transcript is part of Apiiro's 2,539 and it fills up the same way, from command text and command output. Claude Code's settings reference supports Read deny rules, so Read(./.env) and Read(./secrets/**) in your project settings keep those files out of the context window and out of the log that gets committed with the rest of the repository. The approvals file and the transcript folder both belong in .gitignore and both belong in your scanner's path list.
Start with the repositories that are already public
You do not need a project for this. Run the sweep on one engineering laptop this afternoon and sort the output by whether the remote is public. Public and tracked is a rotation that starts now; public and only in history is the same rotation with a slower clock; private and tracked is a thirty-minute fix before somebody makes the repository public for a demo. Then open the two agent config files in whichever repository your team pushes to most and read the allow list line by line. Fifteen lines, four minutes, and you will know whether you are in the 1.1 percent. The median exposure in Apiiro's set ran 217 days, which is a long time for a database password to sit on a default branch that anyone can clone.
If you want a second set of eyes on what your development tooling can reach and where its approvals land, that is a review we run.
Want a second set of eyes on what your development tooling can reach?
We review developer and AI agent tooling for the gaps that end up in public repositories: where secrets live, what the agent can read, and what its approvals write down. Book a session to walk through your setup.
