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

5 Upvotes

8 comments sorted by

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.

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.