The Claude Code team's answer to Apiiro's leak research is one sentence: the settings file is meant to stay local, Claude Code gitignores it when it creates it, so the exposed copies must have been hand-made and checked in.

That sentence is doing a lot of work, and I wanted to know how much. So I rebuilt the failure in a scratch repo with a fake password instead of a real one and watched git at every step. What's worth knowing is narrower than the sentence suggests: the protection described in the docs is a one-way door that only covers a file git has never seen.

What the docs promise, and where the promise stops

The settings docs are specific about the mechanism. The first time Claude Code writes .claude/settings.local.json in a repo that doesn't already ignore it, it adds **/.claude/settings.local.json to your global git excludes file. That's core.excludesFile when your git config points it at a path, otherwise $XDG_CONFIG_HOME/git/ignore. The same page says you don't need to gitignore it yourself unless you created the file by hand.

Two properties of that design matter here, and neither one is a doc error.

The entry is global, so it exists on your machine and nowhere else. A clone, a CI checkout, a teammate's laptop: no entry, because nothing was ever written into the repo's own .gitignore. The rule doesn't travel with the project.

And a gitignore pattern governs untracked files only. The gitignore documentation says it in one line: files already tracked by Git are not affected. A pattern tells git what not to start tracking. It has nothing to say about a path that is already in the index.

I ran it in a scratch repo

There's no git binary in the sandbox I was working in, so I drove the index through dulwich rather than the CLI. Same semantics, and the sequence is short:

  1. Repo, global excludes file containing **/.claude/settings.local.json, and a settings file with a fake PGPASSWORD=... inside one Bash allow rule. The file is ignored. The documented behavior works.
  2. Same file, same content, on a machine with no excludes entry. That's a fresh clone, a CI runner, or a laptop where Claude Code hasn't written the file yet. Not ignored, and a plain git add . stages it.
  3. Add the excludes entry afterwards, once the file is already staged. The ignore rule has no effect on it. It stays staged.
  4. Append a second approval containing a new secret to the now-tracked file. It shows up as an unstaged modification, so the next git add . sweeps it in, and every further "don't ask again" click keeps appending.

So "manually created and checked in" describes a narrower thing than it sounds. The only condition for the file to become trackable is that the global excludes entry isn't in place at the moment somebody runs git add . on that machine. On a fresh clone, that condition is always true.

Once it's tracked, the tool knows, and keeps writing

The permissions docs cover the tracked case directly. When the file is tracked in git, or when .claude is a symlink, Claude Code treats it as repository-supplied and holds its allow rules until you trust the folder. So tracked-ness is detected. What doesn't change is the appending: a new approval still lands in that file. Apiiro quotes the debug log line recording that the file is tracked in the index and that gitignore rules don't apply to tracked files, and notes the user isn't told.

That combination is what makes the allowlist grow in the wrong place. Bigger files hold more secrets, and Apiiro's distribution shows it: one in ten files over 8 KB held a real secret, against fewer than one in 500 of the files under 500 bytes.

The exposed files are mostly older installs

From the npm registry: 562 published versions of the Claude Code package, and 2.1.211, the release that moved the local settings file to the repository root, landed 2026-07-15. 466 of those 562 versions predate it. The newest version I could pull was 2.1.319, on 2026-11-06.

Apiiro's median repo with a real secret first committed the file 217 days before November 2, which lands near the end of March 2026. That's four months before the current placement behavior existed, across hundreds of releases. The exposed population looks like accumulated installs rather than a regression from one bad build, which is why a fix shipping today doesn't shrink the count by itself.

What your secret scanner sees, and what it doesn't

Apiiro ran gitleaks and TruffleHog over all 104,185 public copies. Of the 1,122 files holding a real secret, gitleaks flagged 564 and TruffleHog 643. 215 were flagged by neither.

The blind spot is passwords inside commands. Provider-format detectors match key shapes, and PGPASSWORD=... in front of psql -h has no shape to match. I wrote about this class of gap from a different direction under the name absence blindness: you can't count what your detector was never built to see. Here that's 19% of the files that actually matter, measured against ground truth instead of a vendor claim.

Where other harnesses put the rule

Worth contrasting, because the shape of the stored rule decides what leaks. OpenCode's permission model is a list of action/resource/effect entries in opencode.json, so the rule that ends up in version control looks like read + *.env + deny. A pattern, not a command line. There's no approval record carrying a credential, because there's no per-command literal being saved.

Apiiro compared how 16 coding agents store approvals from their documentation and found Claude Code the only one confirmed to write the full approved command to a project file by default. That's their reading of the docs, not mine, but it matches the file contents they published.

The check that answers "are we exposed"

Order of operations matters more than the tool you buy.

Start with the index, not the ignore file. git ls-files --error-unmatch .claude/settings.local.json at the repo root answers tracked-or-not, which is exactly the question a gitignore pattern can't answer for you. Run the same for .claude/settings.json, the shared file teams commit on purpose, and for the other agent config directories (.cursor, .gemini, .specstory, memory files).

Then walk history: git log --all --oneline -- .claude/settings.local.json. A file deleted last month still exists in every clone and fork, which is why Apiiro found 734 more secrets in earlier versions that appear in no current copy.

Then read the allow list. Each entry is a command somebody approved, secret included. Rotate first. Untracking with git rm --cached plus a repo-level ignore entry doesn't un-leak a value that already shipped.

On the PR side, treat a diff that adds an agent settings file as review-blocking rather than a nit. GitHub Copilot code review skips a documented list of files, so don't assume the bot covered it, and a scanner rule aimed at passwords inside commands will find more here than another provider-key pattern.

If you'd rather keep the custom rules in the repo than in a scanner's vendor list, that's the layer self-hosted reviewers give you. Kodus is open source and self-hostable, and its Kody Rules run org-wide custom checks on the PR, which is a workable home for a rule like "flag any allow entry containing a credential". CodeRabbit, Greptile and Semgrep all offer custom rules or patterns with the same caveat: none of them untracks a file for you. They tell you the diff touched one, and the self-hosted evaluation checklist covers what else to verify before you lean on that.

The review surface grew past the diff

Apiiro's total, 2,539 secrets across 1,994 repos, is small next to the code volume numbers teams quote for AI output. That's the useful part. A chain like agent writes config file, config file records command, command carries secret, file commits with everything else never looks like a code change a reviewer would stop on. It looks like a config file nobody diffed, which is the same routing problem I keep running into when review volume grows faster than the review queue.

Two things worth doing this week. Run the ls-files check across every repo your org owns, since it's one line per repo and it separates "we gitignore it" from "we know". And set approvals to patterns instead of literals, so a rule records Bash(npm run:*) rather than the whole command line with the connection string in it. The literal is what you get from clicking through a prompt you stopped reading at the tenth one.