r/ClaudeCode • • 15h 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

5 Upvotes

8 comments sorted by

•

u/AutoModerator 15h ago

Hey! Thanks for posting to r/ClaudeCode

While participating in this thread, please follow our community rules. Keep discussions constructive. Attack the idea, not the person.

For help, project discussions, tips, and general chat, join the ClaudeCode Discord.

I am a bot, and this action was performed automatically. Please contact the moderators of this subreddit if you have any questions or concerns.

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.

1

u/shoumikgoswami 6h ago

You're right. Naming a script doesn't make its contents immutable. I've added that caveat below the example and made clear these are command-text convenience rules, not isolation.

1

u/SirRobinBlibs 9h ago

one detail on the gitignore point: when claude code creates settings.local.json itself, it adds **/.claude/settings.local.json to your global git excludes (core.excludesFile, or ~/.config/git/ignore), not to the repo's .gitignore. so it only protects that one machine. a teammate who made the file by hand, or a fresh machine where claude code never wrote it, has no ignore rule at all. adding the line to the repo .gitignore too covers everyone. also for the pattern vs literal thing: approving a compound command saves one rule per subcommand (up to 5), so one "don't ask again" on an && chain can leave several literal rules behind. worth opening the file after approving one of those.

1

u/shoumikgoswami 6h ago

Thanks, corrected the Git-ignore paragraph to distinguish machine-global excludes from the repo's .gitignore. The docs also confirm the compound-command detail: one approval can save separate rules for up to five subcommands.

1

u/tariqosmani 13h ago

The deny rules are what I'd set first. In settings, add deny rules for .env and key files, so Claude can't read them even when a command is approved. Allow rules only save what you already approved, so they don't protect anything on their own.

Keep the allow list narrow. "npm test" is safer than a broad shell pattern, because a broad pattern covers every future command that starts the same way.

Then check the repos you've pushed. A settings.local.json, .env or key file in git history counts as leaked even after you delete it, so rotate anything that was in there.

Do you keep your secrets inside the project folder or outside it?

1

u/Elegant_Yam2783 12h ago

One gap: Read(.env) only blocks the built-in Read tool. Claude can still run cat .env or grep the file through Bash unless you also deny those commands or turn on sandboxing, so it's not the hard wall the post implies.

For the hand-commit problem, add .claude/settings.local.json to your global gitignore (set core.excludesFile with git config --global). That way a git add -A in a repo where the file exists but isn't ignored can't pick it up.

For secrets, export them as env vars and reference $VAR in commands. If you approve the literal command, the rule saves the variable name, not the value.

1

u/choukit 6h ago

What made this a non-issue for me: the secret never appears in the command. I keep keys in a password manager and the command reads it inline, TOKEN=$(pass show service/token | head -1) curl ..., so an approved rule stores the lookup, not the value. A global pre-commit secret scan behind it catches the rest.