Claude Code "Don't ask again" and settings.local.json: how to find the passwords in your allow rules, and what to change
On the current Claude Code release the prompt turns most of a command into a wildcard before it saves the rule, and a password written in front of the program still survives. The session transcript keeps the whole command, and the cleanest fix is a command line that never holds the secret.
Contents
On November 5, Apiiro published a count that most Claude Code users can check against their own laptop in five minutes. Of 104,185 public copies of .claude/ on GitHub, 1,122 hold what Apiiro classifies as a real secret: database passwords, cloud keys, API tokens, root SSH logins. Add the file’s git history, the shared settings.json and committed agent transcripts, and the total reaches 2,539 distinct secrets in 1,994 repositories, across 63 organizations. Apiiro tested none of them, so “real” means a known key format or a password next to a public host, not a confirmed working login. One company did confirm its key had been live.
The mechanism is ordinary. Claude Code asks before it runs a shell command, you pick “Yes, and don’t ask again”, and the approval becomes a permission rule in that file. If the command carried a password, the rule can carry it too. I reran the prompt on Claude Code 2.1.319 with placeholder credentials to see what the current release saves. It is not quite what the report describes, and the difference changes what you should look for.
What does Claude Code’s “Don’t ask again” write to settings.local.json?
An allow rule, written to .claude/ at the root of the git repository and applied to every future session there. In my tests on 2.1.319 the offered rule was a prefix with a wildcard, not the full command, and what survived was whatever sat in front of the program, including an environment variable holding a password.
The permissions docs say Bash approvals are saved “permanently per repository and command”, and that the dialog writes the space-separated * form when it offers a command prefix. Here is what the prompt offered for six commands, in a fresh repository, in Manual mode. The first is deliberately artificial, to put an assignment and a flag password in the same command:
command: PGPASSWORD=hunter2placeholder sshpass -p hunter2placeholder ssh app@db.example.invalid true
offered: PGPASSWORD=hunter2placeholder sshpass *
command: PGPASSWORD=hunter2placeholder psql -h db.example.invalid -U app -c 'select 1'
offered: PGPASSWORD=hunter2placeholder psql *
command: DATABASE_URL=postgresql://app:hunter2placeholder@db.example.invalid/app npm run migrate
offered: DATABASE_URL=postgresql://app:hunter2placeholder@db.example.invalid/app npm run *
command: mysql -h db.example.invalid -u app -phunter2placeholder -e 'select 1'
offered: mysql *
command: curl -H "Authorization: Bearer sk-placeholder-0000" https://api.example.invalid/v1/me
offered: curl *
command: psql postgresql://app:hunter2placeholder@db.example.invalid/app -c 'select 1'
offered: psql *I accepted the first one, and the file came out as:
{
"permissions": {
"allow": [
"Bash(PGPASSWORD=hunter2placeholder sshpass *)"
]
}
}So the sshpass -p password went into the wildcard, and the PGPASSWORD= one stayed. That fits the matching rule in the docs: an allow rule “won’t match past an assignment” of any variable outside a built-in known-safe list, so a prefix that should match the next run has to include the assignment, value and all.
Apiiro describes the rule as the exact command, and the files they counted were written by whatever version each developer ran at the time. My tests don’t show when the prompt started suggesting prefixes, so read every file you find, whatever its age. The wildcard also has its own price. curl * and mysql * hold no secret, but they approve every curl and every mysql in that repository from then on, a much bigger grant than the one command on screen. Claude Code’s Bash rules match command text, not the program, so a broad allow is broad in ways the prompt never shows.
Why does settings.local.json end up in git when Claude Code ignores it?
Because Claude Code ignores it in your global git configuration, not in the repository. The ignore entry lives on one machine, and nothing in the project carries it anywhere else.
The settings docs are specific: the first time Claude Code writes the file in a git repository that doesn’t already ignore it, it adds **/. to your global excludes file. That is core.excludesFile when your global git config sets it to an absolute or ~ path, otherwise $XDG_CONFIG_HOME/, or ~/.config/git/ignore when that variable is unset. If you created the file by hand, you’re told to add it to .gitignore yourself.
Apiiro lists where that falls short: a folder that becomes a repository later, a clone on another machine, a file someone already committed. Add a git add -f, a Docker image built with a broad copy, a zip of the project folder. And often the agent commits the file itself: in 539 of the 1,105 repositories with a secret in that file that Apiiro traced, a commit co-authored by Claude changed it.
Once git tracks the file, Claude Code treats it as repository-supplied and holds its allow rules until you trust the folder. That protects whoever clones the repository from your approvals. It does nothing for the password.
How do you check a repository for a committed settings.local.json?
Three git commands per repository, then a code search across the organization. Run them from the repository root, in a full clone rather than a shallow one, since the third command can only search history you have locally:
git check-ignore -v --no-index .claude/settings.local.json
git ls-files ':(glob)**/.claude/settings*.json'
git log --all --oneline -- ':(glob)**/.claude/settings*.json'check-ignore names the file and line that ignore it (--no-index makes it answer for a tracked file too), ls-files lists the copies git tracks now, and log lists every commit that ever touched one. The glob covers nested copies, which matters because before v2.1.211 Claude Code kept the local file in whatever directory the session started in, and it covers the shared settings.json as well.
In my test repository, after committing the file and then untracking it, ls-files printed nothing and log still showed both commits. That is the case to worry about: the file is gone from the tree and the password is still in history, and in every clone and fork that received those commits. Apiiro found 734 secrets that exist only in earlier versions of the file, and 211 forks of 74 repositories carrying the same ones.
For an organization, GitHub’s code search finds the current copies:
Q='.[] | select(.path | test("(^|/)\\.claude/")) | .repository.nameWithOwner + ":" + .path'
gh search code '{' --owner your-org --filename settings.local.json --limit 1000 --json repository,path -q "$Q"
gh search code '{' --owner your-org --filename settings.json --limit 1000 --json repository,path -q "$Q"The { is there because the search wants a term and every JSON file has one, and the filter keeps only files inside a .claude folder, since --filename also matches names like managed-settings.json. gh returns 30 results unless you raise the limit, and code search reads only the default branch of indexed repositories, so an empty result proves little. History still needs the git log line in each repository. Check the shared .claude/settings.json too: teams commit it on purpose, and Apiiro found real secrets in 298 of them, mostly in allow rules.
How do you find passwords in Claude Code allow rules on your machine?
Find every copy of the file, then search them for the shapes secret scanners miss. Most of these files never reach GitHub, and a plain JSON file in a project folder is what credential-stealing malware reads. Apiiro notes the Shai-Hulud npm worm ran TruffleHog over its victims’ files.
PAT='PASSWORD=|sshpass -p|-p[^ "*)]{3,}|Bearer [^ "*]+|://[^/:@ ]+:[^@ ]+@|(api[_-]?key|token|secret)='
find ~ -path '*/.claude/settings*.json' -not -path '*/node_modules/*' -exec grep -lEi "$PAT" {} + 2>/dev/nullIt lists file names, not matches, so the secrets don’t scroll past, and so that a session you run it from doesn’t copy them into its own transcript. Open the files it names in an editor outside the agent. The pattern is noisy on purpose. It flags a password after PGPASSWORD= or mysql -p, a bearer header, a password inside a connection string and the usual token= assignments, and also --port, --prefix and plenty of other hyphenated words, so expect false hits on a large home directory. That’s the trade for catching what dedicated tools skip. Apiiro ran gitleaks and TruffleHog over all 104,185 files: of the 1,122 with a real secret, gitleaks flagged 564, TruffleHog 643, and 215 were flagged by neither. A password typed into a command is an ordinary word on an ordinary command line, with no provider prefix to match.
Then search the session transcripts, which keep more than the rule does. Claude Code stores each session as JSON lines under ~/.claude/projects/, and the tool call is recorded as Claude wrote it. In my test the sshpass -p password that the rule had replaced with * was in the transcript in full, and so were the commands I refused:
grep -rlEi "$PAT" ~/.claude/projects/ 2>/dev/nullThis one matters even if you never click “Don’t ask again”. When no prompt appears, as in bypassPermissions mode, no rule gets written, and the transcript records the command anyway.
What should you do first after finding a secret in settings.local.json?
Rotate it. Deleting the rule, untracking the file or rewriting history removes your copy and none of the others, so the credential has to stop working before the cleanup is worth anything. Apiiro’s order is the same: rotate first, clean up second, and rotate every credential the file has ever held.
Then, in each repository where ls-files showed the file as tracked:
git rm --cached .claude/settings.local.json
echo '.claude/settings.local.json' >> .gitignore
git add .gitignore
git commit -m "Stop tracking Claude Code local settings" -- .gitignore .claude/settings.local.jsonThe pathspec on the commit keeps anything else you had staged out of it. In every repository, tracked or not, delete the rules that hold the secret with /permissions or by editing the file, and keep that .gitignore line, so the protection travels with the code instead of living in one machine’s git config. If you also rewrite history to purge the secret, do it after the rotation and treat it as hygiene: clones and forks already have the old commits.
How do you keep secrets out of Claude Code permission rules?
Keep the secret off the command line. The textbook way to do that, a $VAR reference, prompted on every run in my test; a credential file or a fixed-purpose script did not.
Apiiro’s advice, like most, is to reference the secret as $VAR, so the rule records the name and not the value. For the one command I tried, psql "$DATABASE_URL" -c 'select 1', the prompt offered no “don’t ask again” option at all. When I wrote Bash(psql *) into the file by hand (Claude Code reloads the file on save, and a rule I added the same way a few minutes later applied without a restart), the same command still prompted, with this line:
A variable in this command can’t be checked before it runs
That is a sound rule for a permission system, since the command text can’t tell Claude Code what the variable expands to. It also means the $VAR form keeps asking, and a prompt that keeps asking is a plausible way back to the inline value.
The better fix is to let the tool find its own credential. psql reads ~/.pgpass, one hostname: line per server, and ignores the file unless its permissions are strict, so run chmod 0600 ~/.pgpass. The command then carries no password, provided nothing else in it does: drop the password from the connection URI too. It is still a plaintext file, just one outside your projects. SSH keys replace sshpass -p entirely.
Where no such file exists, a wrapper script can hold the lookup. In my test, a script that read DATABASE_URL from its own environment, approved with this rule, ran without a prompt:
{
"permissions": {
"allow": [
"Bash(./scripts/db-query.sh *)"
]
}
}That rule names a file you wrote rather than a credential, but it authorizes whatever the script does with its arguments, and the script can change after you approve it. A wrapper that passes free text to psql -c hands over a full client, backslash commands included. Give the script one fixed job (a status check, a named report), take no free-form input, give it a database login that can do only that job, and prefer an exact-match rule with no *. It also must never echo the secret, because its output lands in the transcript too.
Two smaller habits help. A Read(./.env) deny rule stops Claude’s file tools from pulling the value into context, though the docs are clear it does not cover a script that opens the file itself. And don’t move secrets into the env block of settings.local.json, which only relocates them into the file this whole article is about. The sandbox is a different layer: it limits where commands can reach, and it doesn’t redact a secret that is already written into a tool call or a rule.
Does the Claude Console IP allowlist help with a leaked Claude API key?
For a Claude Platform API key, as a backstop. It limits where a leaked key works, not how long, and it does nothing for the database passwords and other non-Claude credentials in Apiiro’s set.
The IP allowlisting docs describe a beta that an organization admin or owner turns on in the Console under Identity and access. It covers the Messages, Batches, Token Counting, Models, Files and Skills endpoints, the Claude Managed Agents endpoints, and Admin and Compliance API keys that cover only that organization; a request from outside the list gets a 403 permission_error. It does not cover Bedrock, Claude Platform on AWS, Google Cloud or Microsoft Foundry. Entries go no broader than /8 for IPv4 or /32 for IPv6, and changes take up to 15 minutes. It suits keys used from servers and CI with fixed egress addresses, not laptops on changing networks, and Anthropic’s own page recommends a short key expiration or Workload Identity Federation alongside it.
Claude Code’s allow list was never the place for a secret
The rule file is the copy you can see, and the transcript is the complete one. The 2.1.319 prompt keeps most arguments out of the rule it suggests, and in these tests it still kept a password written as a prefix. A fix that doesn’t depend on what the prompt saves is a command line that never contains the secret: then the rule, the transcript entry for that command and the commit have nothing to keep, whatever the next version’s suggestions look like. Output and file reads can still carry a secret into a session, so this closes one route, the one Apiiro counted. A prompt you have stopped reading is a poor place to make that decision, which is why the approvals worth a person’s attention are the rare ones.
01 / Discussion
No comment section here — all discussions happen on X.
02 / More articles
Claude Code and your email in a User-Agent: what the privacy mod checks, and what a hook and an allowlist add
Nobody built a feature that mails your address to websites. The model finds it in its context, meets a site that asks scripted clients for a contact, and helps. Every fix on offer filters that address on its way out, so rank the filters by what the model cannot argue past, and know which routes each one leaves open.
Prompt injection examples against coding agents: where the text got in, and what, if anything, stopped it
Nine public cases, sorted by the door the attacker's words came through. In most of them nothing held during the attack. The fixes that came after were all of one kind: a deterministic check re-aimed or a capability removed. A model's judgment that failed had nothing to re-aim.
Claude Code extension backdoor in Cursor's marketplace: what ran, what uninstalling leaves, and why the version number can't tell you which build you got
The tampered builds carried Anthropic's publisher name and, in one case, a version Anthropic really shipped. Neither of those identifies who built the bytes, and the parts that matter stay on the machine after the extension is gone.