Don't Ask Again, Publish Forever: How a Claude Code Permission File Leaked 2,539 Secrets to GitHub
On November 2, 2026, a security researcher sent an uncomfortable email to a database vendor. The message told the company that its own admin-level cloud API key, with manage access and no expiry claim, was sitting in a public GitHub repository the company used for demos and workshops. The key had not been pasted into a README or left in a config file by a careless intern. It was stored, character for character, inside a permission rule in a file called .claude/settings.local.json, and it had landed there the day a developer clicked a single button labeled "Yes, and don't ask again." That vendor was one of 63 organizations Apiiro notified on November 2 and 3 after scanning every public copy of that file on GitHub. The count behind the emails is the story: 104,185 public copies of .claude/settings.local.json in 103,617 repositories, of which 1,122 held a real secret. Add the file's git history, the shared settings file teams commit on purpose, and saved agent transcripts, and the total reaches 2,539 distinct real secrets across 1,994 repositories. This article walks through the mechanism that puts a password into a permission rule, the data on what actually leaked, why two standard scanners miss a fifth of it, and the concrete audit and configuration changes that close the hole. The file that remembers what you approved Claude Code reads its configuration from a small set of JSON files with different scopes, and the distinction between them is the whole story. Anthropic's settings documentation lists four: a user-level file at ~/.claude/settings.json that applies to every project on your machine; a shared project file at .claude/settings.json that teams are expected to commit; a project-local file at .claude/settings.local.json meant for personal overrides in one project; and managed settings an organization can deploy as policy. The permissions documentation adds the behavioral half. When Claude Code wants to run a shell command in the default manual mode, it shows the command and asks. One of the answers, "Yes, and don't ask again," does two things at once: it runs the command, and it saves a rule so the prompt never appears again. For Bash commands and WebFetch domains, that rule is saved permanently, and the place it goes is .claude/settings.local.json at the root of the git repository, resolved through worktrees to the main checkout. The rule is not a pattern the tool generalizes for you. The rule is the command string itself, exactly as approved. The official docs are aware of the risk this creates. The settings page says Claude Code "keeps it out of git when it creates the file," and that if you create the file by hand you should add it to .gitignore yourself. That sentence is doing enormous work, because the protection has a precise boundary: Claude Code adds the file to the developer's global git ignore list, not to the repository's own .gitignore. Nothing travels with the project. From one click to a public commit The leak chain has four links, and each one is individually reasonable. A developer asks the agent to check why login fails on staging. The command the agent proposes cannot work without the credential, so the secret from the local config file lands inside the command line, as in PGPASSWORD followed by the real database password, or an sshpass invocation with a root password inline. The developer reads the command, sees it is the right fix, and clicks "don't ask again" because being asked every time is friction. The literal command, secret included, becomes an entry in the allow list. The fourth link is where the protection boundary matters. The global git ignore covers the common case: same machine, same user, repo already initialized. It does not cover a folder that only later becomes a repository, a project directory copied to another machine or container without that global ignore, or a file that was committed once already, because ignore rules do not apply to tracked files. Once the file is tracked, every clone and every fork carries it, and if the repository is public, so is the secret. One more detail from the study deserves attention because it closes the usual defense that this is purely user error. In 539 of the 1,105 repositories with a real secret that Apiiro traced, a commit co-authored by Claude itself changed the file. The commits name every recent model. The agent that wrote the rule is frequently the agent that commits it, as part of an otherwise sensible "commit my changes" request. What the 1,122 files actually hold The composition of the leaked material is what turns this from hygiene into an incident. Of the 1,379 distinct secrets in the 1,122 current files, 636 are keys in a known provider format, and 743 are passwords or tokens paired with a public host. More than a third open a database directly: 379 database passwords and 122 Supabase service-role keys, plus 103 Supabase account tokens that control every project in the account. The remainder includes 43 SSH credentials for public servers, some of them root passwords inside sshpass commands, 42 GitHub, GitLab and npm tokens, 23 AWS keys, and 6 private keys. Two distributions in the data change how you should think about your own exposure. The first is size: one in ten files over 8 KB held a real secret, against fewer than one in 500 files under 500 bytes. The allow list grows every time a developer clicks the button, so the leak rate is a proxy for how long and how heavily the agent has been used. The second is age. In the median repository with a real secret, the file was first committed 217 days before the November 2 snapshot, and in 118 repositories it had been there for more than a year. Forks multiply the copies: 211 forks of 74 repositories carry the same secrets. Deleting the file today does not reach any of that. Earlier versions of the file hold another 734 real secrets across 509 repositories that appear in no current copy. Why the scanners walked past The natural response is that secret scanning should have caught this. Apiiro ran gitleaks and TruffleHog over all 104,185 files, and the result is the most actionable number in the study for security teams. Gitleaks flagged 564 of the 1,122 files, TruffleHog flagged 643, and 215 files, roughly one in five, were flagged by neither. The gap is structural. Both scanners look for key formats: a provider prefix, a long random string with the right entropy. A password typed after PGPASSWORD=, sshpass -p, or mysql -p, or a bearer header inside an approved curl command, is an ordinary word in an ordinary command line. There is no format to match, and the tools mostly let it through. This is also why the finding generalizes: agents write the same password-in-a-command pattern into transcripts, memory files, and handoff notes, and a third of the handoff notes with a secret in the study were written by the agent itself. Any scanner tuned only to provider key formats will miss the pattern everywhere the agent records what it ran. There is a sharper way to see the stakes. In September 2025 the Shai-Hulud npm worm downloaded TruffleHog onto victims' machines and ran it over their files. That same scanner flags 643 of the 1,122 files. Three weeks earlier, the malicious Nx packages told the developer's own Claude Code and Gemini CLI to search the disk for secrets. Credential-collecting malware already knows these files exist. The copy on the laptop is not safe either Most of the 104,185 files' siblings never reach GitHub. They sit in plain text in project folders on developer machines, and the study is blunt about why that is not a safe harbor. The file can leave without git: inside a published npm package, a Docker image built with a copy instruction, a zip archive, or a synced folder. Apiiro found 126 of 31,053 Hugging Face Spaces carrying the file, one with allow rules containing a database service key and a user password, and two real secrets among 69 public copies checked on GitLab, including a GitLab access token. A secret approved once also stays in the file until someone deletes the rule, long after the command that needed it ran. The allow list is not a cache that expires; it is an accumulator. Combined with the observation that file modification approvals, by contrast, only last until the session ends, the design asymmetry is clear: the least durable approval protects the most reversible action, while the most durable approval records the most sensitive strings developers type. Audit and clean up in an afternoon The remediation sequence matters, and the study's ordering is correct: rotate first, clean up second. A secret that was ever committed must be treated as live until rotated, because removing the file does not clear git history, forks, or clones. The concrete checks take minutes. Start with visibility, then history, then content: # Is the file public? Search your GitHub org for: # .claude/settings.local.json # .claude/settings.json # Was it ever committed, even if deleted later? git log --all --full-history --oneline -- .claude/settings.local.json # Untrack it without deleting your local copy git rm --cached .claude/settings.local.json Then look inside every copy of the file you can find, on every machine, for the pattern the format scanners miss. A password inside a command does not match a key regex, so grep for the shapes instead: rg -n --no-heading "PGPASSWORD=|sshpass -p|mysql -p|Authorization: Bearer|api[_-]?key=" .claude/ Finally, make the fix survive the next clone. The repository's own .gitignore is the only protection that travels with the project: # .gitignore — travels with the repo, unlike the global ignore .claude/settings.local.json .env Approve patterns, not commands The durable fix is to change what gets saved. The permission system supports prefix rules, and Anthropic's documentation spells out the syntax: a rule like Bash(npm run:*) approves every command under that prefix without recording any single invocation. A pattern rule contains no literal command, and therefore can never contain a literal secret. When a one-off command genuinely needs a credential, the secret belongs in the environment or a secrets manager, so the command line and anything that records it holds only a reference. In Python that looks like: import os import subprocess dsn = os.environ["DATABASE_URL"] # reference, not a literal subprocess.run(["psql", dsn, "-c", "select 1"], check=True) The same discipline applies to existing allow lists: open the file and delete every rule whose command string contains a secret, even if the file was never committed. Treat the rule entry itself as the credential's twin. What teams should change this week For organizations, the study's checklist reduces to treating agent folders as secret-bearing, exactly like .env. That means adding .claude/, .cursor/, .gemini/, and agent memory files to pre-commit and CI secret scanning, including history; making sure the scanner catches passwords inside commands, not only provider formats; adding .claude/settings.local.json to the organization's .gitignore templates so the protection is structural rather than remembered; and triaging credentials paired with public hosts first, because those are the ones that grant access. Managed settings exist precisely so the policy layer does not depend on each developer's clicking habits. It is worth stating the boundary of the finding honestly, because the Hacker News discussion lit on it quickly: clicking "don't ask again" does not publish anything by itself, and Claude Code never uploads the file. The exposure requires the file to be committed or otherwise shipped, and in a private repository the blast radius is everyone with access rather than everyone on the internet. But the defaults push in the wrong direction at every step: the rule stores the literal command, the ignore protection does not travel with the repo, the agent frequently commits the file itself, and the accumulation never expires. A mechanism reported in GitHub issues since August 2025 and measured at scale in November 2026 is not an edge case; it is what the tool does when used exactly as designed. If you have ever approved a command that carried a password, the ten-minute audit above is the cheapest incident response you will run this year. Originally published on Dispatch.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to