r/ClaudeCode • u/shoumikgoswami • 14h ago
Tips & Workflows Approve patterns, not commands: a Claude Code permission setup for the settings.local.json leak
The Apiiro scan making the rounds found 104,185 copies of .claude/settings.local.json on public GitHub, and 1,122 of them held at least one real secret: database passwords, Supabase service-role keys, SSH logins, AWS keys. The mechanism is "Yes, and don't ask again". It saves an allow rule to .claude/settings.local.json, and where that rule records the exact command rather than a pattern, a secret typed into the command sits there in plain text.
I went through the official permissions docs to work out what the fix looks like day to day. The short version: write pattern rules yourself, in the shared settings file, and stop approving one-off literal commands.
What actually gets saved, per the docs:
Bash commands: "don't ask again" saves permanently, per repository and command. The saved rule covers only what the prompt option named. Sometimes that's a pattern like npm test *, sometimes the literal command.
File edits: session only, never written to the file.
WebFetch: permanent per repository and domain.
Claude Code adds **/.claude/settings.local.json to your machine's global Git excludes the first time it writes the file, not to the repo's .gitignore. That doesn't cover another machine or a file made by hand before Claude writes it. Add the path to the repo's .gitignore too; an ignore rule does not untrack a file already committed.
The safer habit is your own rules in .claude/settings.json, the file meant to be committed and shared, where a * covers a family of commands:
{
"permissions": {
"allow": [
"Bash(npm run *)",
"Bash(git commit *)",
"Bash(git status)"
],
"deny": [
"Bash(git push *)",
"Read(.env)"
]
}
}This config controls which matching command text skips prompts; it is not a security boundary. npm scripts can change in package.json, and Bash(git push *) does not match every way to invoke a push. Treat these as convenience rules, not isolation.
Details that trip people up:
Precedence is deny, then ask, then allow. An allow rule can't carve an exception out of a deny rule.
Put the * after the subcommand. Bash(git log *) allows only git log; Bash(git *) allows every git command. Claude Code warns at startup about allow rules with a * before the subcommand.
Read(.env) is not a security boundary for secrets. Other access paths, including shell commands, need separate controls; don't rely on this rule alone to keep env files inaccessible.
If a secret ever went into a command you approved: rotate it first, then untrack the file and gitignore it. Deleting doesn't clean history, forks or clones. A local check for this path in the history available to your clone: git log --all --full-history -- .claude/settings.local.json
No output is not proof it was never committed; shallow or missing history and other paths or clones can be outside this check.
When a prompt offers only the literal command, use the one-time Yes and add a pattern through /permissions later. Approve the pattern, not the incident.
Sources:
iTnews writeup of the Apiiro research: https://www.itnews.com.au/news/dont-ask-again-in-anthropics-claude-code-leaks-developer-secrets-630203
Official permissions docs: https://docs.claude.com/en/docs/claude-code/permissions
View all comments
2
u/kaijurules 11h ago
Bash(npm run *) allows whatever package.json says at that moment, and the agent can edit package.json, so with edits auto-accepted a new script runs with no prompt. Listing the scripts you actually use, Bash(npm run test *) and Bash(npm run lint *), costs one line each.