Attributed compile + local reproduction with fake secrets on my box
Primary source: Coding Agents Copy Your Secrets to a Hidden File. Thousands Are on GitHub by Shmulik Cohen and Matan Giladi, Apiiro (Nov 5, 2026; updated Nov 8). The page is rendered client-side and our plain fetch got no article text, so we read the report's figures through the write-ups below.
Discussion: Hacker News thread (600+ points), including a reply from Boris Cherny of the Claude Code team.
Write-ups we relied on for figures: LATENT: inside 1,122 files, and the 19% two scanners missed (Nov 8) · The Hacker News · iTnews · Chinese: Piotr Sobolewski (zh).
Mechanism docs: Claude Code settings · Claude Code permissions.
Counts and percentages below come from Apiiro's research (as quoted by those outlets), not from us. Our own results come only from a local git + scanner reproduction using fake credentials. We did not run Claude Code, search GitHub for other people's files, or test any real credential. Artifacts:/workspace/reputation/runs/37-claude-code-dont-ask-again/.
The most-discussed coding-agent story of the weekend wasn't a model launch. It was a JSON file. When Claude Code asks to run a shell command and you pick "Yes, and don't ask again", it saves an allow rule in .claude/settings.local.json. If the prompt offered the exact command rather than a pattern, the saved rule is that exact command. So if the command was PGPASSWORD=... psql ..., the password is now sitting in a plain-text file inside your project.
Apiiro looked for that file on public GitHub. That alone isn't news; any tool that writes state can leak. What makes it useful for builders is that each layer of the safety story has a specific gap you can test yourself. So this post skips the headline numbers and walks the gaps one at a time. I reproduced each one on a throwaway repo, and the post ends with a small rule-aware scanner and a pre-commit hook.
What Apiiro reported (their numbers, not ours)
| Item (Apiiro, via LATENT / THN / iTnews) | Value |
|---|---|
Public .claude/settings.local.json files collected (Nov 1–3) | 104,185 in 103,617 repos |
| Files with ≥1 "real" secret | 1,122 (~1.1%) |
| Distinct secrets in those files | 1,379 |
Incl. history, shared settings.json, agent transcripts | 2,539 across 1,994 repos |
| Repos where a Claude-co-authored commit touched the file | 539 of 1,105 |
| Files caught by gitleaks / TruffleHog / neither | 50.3% / 57.3% / 19.2% |
Two caveats Apiiro states themselves: it did not test whether the credentials work (it classified them by format or by a password sitting next to a public host, then confirmed with an LLM reviewer), and one notified company confirmed its key had been live. The rule shapes it highlighted are the ones you'd expect: PGPASSWORD= before psql, sshpass -p, curl with a Bearer header, and connection strings like postgres://user:pass@host.
The mechanism: three files, one machine-local guard
According to the settings docs, the first time Claude Code writes settings.local.json in a git repo it adds **/.claude/settings.local.json to your global git excludes file (core.excludesFile, otherwise ~/.config/git/ignore). The CLI only writes to the local file. In the VS Code extension, the approval card lets you choose the shared .claude/settings.json, which is designed to be committed.
That guard lives in one user's git config on one machine. It doesn't travel with the repo. Apiiro's author named the cases where it fails (HN): a new machine or container without the global exclude, a copied project folder, and a file that's already tracked, because git never applies ignore rules to tracked files. The docs also say a file you create by hand isn't covered.
Reproducing the gap (fake secrets, real git)
I wrote a fixture settings.local.json with ten allow rules. Four are safe: Bash(npm run test *), Bash(git status), a WebFetch domain, and a psql that reads $DB_PASSWORD. Six embed fake credentials in the shapes Apiiro described. One of those is a randomly generated ghp_-shaped token, so format-based scanners have something to match. Then I ran run.sh with two isolated HOME directories standing in for two machines:
1. Machine A, global exclude present -> git status hides the file; 'git add -A' does not track it 2. Same folder copied, no exclude -> '?? .claude/settings.local.json'; 'git add -A' tracks it 3. Clone that back onto machine A -> exclude pattern still matches (check-ignore --no-index), but a new saved rule shows ' M .claude/settings.local.json' 5. git rm --cached + repo .gitignore -> untracked going forward; 'git show HEAD~1:...' still prints the fake secrets from history Step 3 is the one that trips people up. Once a teammate's container or CI runner has committed the file, your own perfectly configured laptop will keep committing every new approval.
What scanners caught on this fixture
Same committed file, default settings, verification off:
| Scanner (version) | Flagged rules out of 6 | What it caught |
|---|---|---|
gitleaks 8.30.1 (git mode) | 1 | github-pat (the ghp_-shaped fake) |
TruffleHog 3.99.8 (filesystem --no-verification) | 2 | GitHub token + the postgres://user:pass@host URL |
scan_allow_rules.py (below) | 6 | all six, 0 hits on the 4 safe rules |
Don't read this as a benchmark. It's six rules I wrote, so the regexes match my own fixture, and a hand-rolled scanner will have its own misses. It does match the mechanism behind Apiiro's 19%: a password after sshpass -p or mysql -p, or one in a PGPASSWORD= assignment, looks like an ordinary command argument to a scanner that hunts for provider prefixes and high entropy. Keep your general scanner. Add a check that understands this one file format.
A rule-aware check for the allow list
The script parses permissions.allow/ask/deny and flags a rule when a secret-ish slot contains a literal rather than a $VAR. Core patterns (excerpt; the full ~40-line script also redacts its output and exits non-zero on any hit):
LIT = r'(?!\$)[^\s"\'$]{4,}' # literal, not a $VAR reference PATTERNS = { "env-assign-secret": r'\b[A-Z_]*(PASS(WORD)?|TOKEN|SECRET|API_?KEY)[A-Z_]*=' + LIT, "sshpass -p": r'sshpass\s+-p\s*[\'"]?' + LIT, "mysql -p<pw>": r'\bmysql\b.*\s-p(?!\s)' + LIT, "bearer header": r'[Bb]earer\s+' + LIT, "url userinfo": r'://[^/\s:@]+:[^\s"\'$]{3,}@', "provider prefix": r'\b(ghp_|github_pat_|sk-|AKIA|xox[bp]-|glpat-|npm_)[A-Za-z0-9_\-]{8,}', } Use it as a pre-commit hook that only looks at staged .claude/settings*.json files:
#!/usr/bin/env bash files=$(git diff --cached --name-only --diff-filter=ACM | grep -E '(^|/)\.claude/settings(\.local)?\.json$') [ -z "$files" ] && exit 0 python3 scan_allow_rules.py $files || { echo "literal secret in agent allow rule; use \$VARS or a pattern"; exit 1; } In the run, git add -f followed by commit was blocked with exit 1. Since the hook is local, put the same check in CI too, where nobody can skip it with --no-verify.
The fix order that actually matters
- Rotate first. If the file was ever public, deleting it changes nothing for anyone who already cloned or forked. My step 5 shows history still serving the secrets after the untrack commit. Database and SSH passwords don't auto-revoke the way some provider tokens do.
- Find every copy:
git ls-files .claude,git log --all --oneline -- .claude/settings.local.json .claude/settings.json, and the file on your own disk even if it was never committed. Check npmfiles/.npmignore,.dockerignore, and zip/sync paths too. A global git exclude does nothing outside git. - Untrack and make the ignore travel with the repo:
git rm --cached .claude/settings.local.json, then add the path to the repo's.gitignore. - Change the habit: reference secrets as
$DB_URLso the saved rule stores the variable name. Approve patterns likeBash(npm run test *)instead of literal commands. If a prompt offers "don't ask again" on a command containing a credential, choose plain "Yes". - Rewrite history last, with
git filter-repo, and only after rotation.
On the vendor side, Cherny said on HN that Claude Code hasn't historically scrubbed secrets and that the team is prototyping options, including having auto mode catch this and built-in basic secret scanning. He also recommended auto mode over hand-maintained allow lists. Until a fix ships, treat .claude/ the way you treat .env: a file that will hold credentials sooner or later, and one your tooling has to know about.
What this box actually did
2026-11-09 ~09:11 America/Toronto NO CLAUDE CODE RUN / NO GITHUB SEARCH / NO REAL CREDENTIALS / NO VERIFICATION CALLS HTTP 200: HN item + Cherny comment (Firebase API), LATENT, THN, iTnews, sobolewski.pro (zh), Claude Code settings + permissions docs, claude-code issue #5544, gitleaks + trufflehog repos Apiiro report: 200 but client-rendered shell (no article text); figures via LATENT/THN/iTnews Ran: run.sh (two fake HOMEs = two machines) with git 2.47.3, gitleaks 8.30.1, trufflehog 3.99.8, scan_allow_rules.py; fixture = 10 rules (4 safe, 6 fake-secret) Observed: exclude hides file on A only; copy/container tracks it; tracked file keeps changing past the exclude; gitleaks 1/6, trufflehog 2/6, rule-aware 6/6 (0 FP on 4 safe); history retains secrets after untrack; pre-commit hook blocks re-add (exit 1) NOT tested: Claude Code UI behaviour itself; VS Code extension destination picker; auto mode; GitHub push protection; Apiiro's corpus; scanner custom rules/verification YongBo Yu, Toronto AI engineer (agent systems / LLM workflows). Site: yongbo-yu.vercel.app · GitHub: YongBoYu1
