Updated 2026-11-09. Choosing "Yes, and don't ask again" makes Claude Code save the approved command as an allow rule in .claude/settings.local.json, and the docs say it keeps that file out of git only when it creates the file itself. A file you made by hand, or that was already tracked, carries the credential.
.claude/settings.local.json
# Copy-paste starter: does this repo track an agent settings file that holds a key? git ls-files --error-unmatch .claude/settings.local.json 2>/dev/null \ && grep -oE '(Bearer|sk-|ghp_)[A-Za-z0-9._-]{8,}' .claude/settings.local.json \ || echo "not tracked, or no key-shaped string in it"
Anthropic's settings page states the behaviour plainly: when Claude asks permission to run a Bash command and you choose "Yes, and don't ask again", Claude Code saves that approval as an allow rule in .claude/settings.local.json [1]. The same page carries a table of which file affects whom, and the row for the project local file says it is for "you, in this one project only", then adds the sentence that matters here: Claude Code keeps it out of git when it creates the file; if you create it by hand, add it to .gitignore yourself [1].
.gitignore
That single sentence is the whole vulnerability. The protection is conditional on who wrote the file, and nothing in the approval dialog tells you which case you are in.
The file's documented shape is ordinary JSON under a permissions key, with each entry naming a tool and what it may do. Anthropic's own example, which I recommend copying because it also denies the obvious secrets [1]:
permissions
{ "$schema": "https://json.schemastore.org/claude-code-settings.json", "permissions": { "allow": [ "Bash(npm run lint)", "Bash(npm run test *)" ], "deny": [ "Read(./.env)", "Read(./.env.*)" ] } }
The allow list is where an approved command lands, and an approved command is copied as text. Approve curl -H "Authorization: Bearer $TOKEN" ... once, after pasting the real value, and the real value is now a line in that file. The rule syntax itself is documented on the permissions page [2].
allow
curl -H "Authorization: Bearer $TOKEN" ...
This is the part that surprises people, and it is why a grep for one filename is not enough.
.claude/settings.json
.git
.claude
The fourth row is the one I would have missed. The docs say a session started in a subdirectory now reads and writes the file at the repository root, and that in a worktree it uses the file at the main checkout's root, but that before v2.1.211 Claude Code kept the file in the starting directory and still reads a file an earlier version left there, with permission rules from both files applying [1]. So a machine that has been running the tool for months can have a stale local file several directories deep, and a .gitignore entry at the root will not cover it.
The reference page adds a second consequence that is easy to miss: once git tracks that file, Claude Code stops treating it as yours. The page says directories added in a local settings file only count "unless git reports that file as untracked", and that "in a directory that isn't a git repository, or when git tracks the file, Claude Code treats .claude/settings.local.json as repository settings" [5]. The file you created for yourself becomes repository-supplied the moment it is committed, which is the same trust downgrade the settings page describes.
The third row is worse in a different way. If you run the tool in a folder that is not a git repository at all, the approval goes into .claude/settings.json, which the documentation explicitly describes as the shared file you commit so your teammates get the same permissions [1]. Nothing about that flow is wrong for a team that wants shared rules. It is wrong for a credential, because the file is designed to be published inside the repository.
The check below is the one I would run first in any repository that has used an agent. It finds the candidate files, asks git whether each one is tracked, and looks for credential-shaped strings without printing them.
#!/usr/bin/env bash # audit-agent-settings.sh: report tracked agent settings files that hold a credential. # Exit 1 means at least one tracked file carries a key-shaped string. set -uo pipefail fail=0 for f in .claude/settings.local.json .claude/settings.json .mcp.json .cursor/mcp.json \ "$(git rev-parse --show-toplevel 2>/dev/null)/.claude/settings.local.json"; do [ -f "$f" ] || continue if git ls-files --error-unmatch -- "$f" >/dev/null 2>&1; then if grep -qE '(Bearer[[:space:]]+[A-Za-z0-9._-]{20,}|sk-[A-Za-z0-9_-]{20,}|gh[pousr]_[A-Za-z0-9]{30,})' "$f"; then echo "FAIL tracked with a credential: $f" fail=1 else echo "ok tracked, no credential found: $f" fi else rule=$(git check-ignore -v -- "$f" 2>/dev/null || echo "not ignored") echo "ok not tracked: $f ($rule)" fi done exit $fail
I ran this in a throwaway repository that reproduces the accident: one .claude/settings.local.json, committed, containing an allow entry whose command carries a bearer token, plus an MCP server block with an Authorization header. Output, verbatim:
Authorization
FAIL .claude/settings.local.json [TRACKED] bearer token on line 5: sk-l...00 (44 chars) bearer token on line 8: sk-l...00 (44 chars) fix: git rm --cached this file, add it to .gitignore, rotate the key checked 1 candidate file(s), 1 tracked file(s) carrying a secret
Two lines, because the same token appeared twice: once inside an approved Bash(...) entry and once in an mcpServers header. The masking is deliberate, and doing it this way is not politeness. A scanner that prints the secret into a terminal, a CI log or a ticket has moved the leak rather than found it.
Bash(...)
mcpServers
The Node version of the same check lives in this repository at scripts/audit-agent-permissions.mjs and adds two things the shell version does not: it expands the glob shapes properly, and it reports which .gitignore line covers a file when one does, so you can tell "ignored on purpose" from "ignored by accident".
scripts/audit-agent-permissions.mjs
Untracking the file stops the next commit. It does not remove the copy that is already in the object database, and this is where people stop too early.
# Stop tracking, ignore, commit. No history rewrite, no force push. git rm --cached .claude/settings.local.json printf '.claude/settings.local.json\n.claude/*.local.json\n' >> .gitignore git add .gitignore git commit -m "Stop tracking agent-local settings and ignore them" # Now prove the old copy is still reachable from history. git grep -c "sk-" $(git rev-list --all) -- .claude/settings.local.json 2>/dev/null
Run in the throwaway repository after the purge, the audit prints 0 tracked file(s) carrying a secret and exits 0, and the history search does not come back empty:
0 tracked file(s) carrying a secret
297b81c6ea2d1ca66500358334dd1986c05c8f9c:.claude/settings.local.json:2
One commit, two hits. That output is the reason the next step is not optional: the commit is public to everyone with repository access, and it stays there until you rewrite history or rotate the key. Rewriting history on a shared branch means a force push, which breaks everyone else's clone and is the kind of thing to decide with the whole team. Rotating the key is one vendor dashboard visit and it makes the exposure in history worthless. Rotate first, then decide whether the rewrite is worth it.
If the repository is public, assume the key is already in someone's dataset. Secret scanners and crawlers work on public repositories continuously.
It is tempting to treat secret scanning as a net under all of this. GitHub's documentation is precise about the scope, and the precision is the point: "Public repositories: Secret scanning runs automatically for free. Organization-owned private and internal repositories: Available with GitHub Secret Protection enabled on GitHub Team or GitHub Enterprise Cloud. User-owned repositories: Available on GitHub Enterprise Cloud with Enterprise Managed Users" [3].
Read that as three buckets. Public: automatic and free, so a leaked key gets an alert. Organization-owned private: needs a paid plan. A private repository owned by a user account, which is the shape most single-founder projects have, is not in the automatic bucket at all. Our own repository is exactly that shape: private, owned by a user account. Nothing would have alerted us.
That asymmetry matters because private feels safe. It is not the same as scanned.
I ran the check above against the repository that builds this site while writing this post. It found one: .claude/settings.local.json, tracked since October, containing an MCP server block with an Authorization: Bearer header for a tool server, plus the same value as an allow-listed command. File mode 600, which protects nothing once git has a copy.
Authorization: Bearer
The fix is the sequence in recipe 2, and it is committed: the file is untracked and both ignore patterns are in .gitignore. The key is on the rotation list, which is the part I cannot finish for you in an article, because rotation happens in the vendor's dashboard. What I can tell you is the honest sequence: untracking took one command, and the exposure in history is still there in commit 9b5a0ed until that key is rotated.
9b5a0ed
I am describing our own accident rather than a client's, and there is no client result in this post. That is the rule here: no invented numbers, no borrowed case studies.
Anthropic has not changed the behaviour, which is worth knowing before you plan around it. On the Hacker News thread where this surfaced, a Claude Code engineer posting as bcherny wrote on 2026-11-07 that the team has "a few possible approaches: making sure auto mode catches this, building in basic secret scanning, a few other ideas" and is "going to prototype a few of these to understand the tradeoffs better before we ship something" [6]. The dated changelog still ends at 2.1.319, and the permissions page carries a modified date from before the thread.
bcherny
Two things follow. This is current behaviour rather than a fixed bug, so an old repository can still carry a key today. And the same engineer's advice in that thread is to turn on secret scanning whatever agent you use, which is the next section with its limits.
Anthropic documents two failure modes for a broken settings file, and they are worth knowing by name because they behave differently [1]:
A warning is the dangerous one for this topic. The rest of the file still applies, so a hand-edited file with one bad entry keeps its allow rules live while you are looking at something else.
JSON mistakes surface as Node's own parser message, which is accurate and unhelpful in equal measure. From a file with a trailing comma:
SyntaxError: Expected double-quoted property name in JSON at position 44 (line 2 column 43)
And the git errors you will hit in recipe 2, copied from real runs:
$ git check-ignore -v x fatal: not a git repository (or any of the parent directories): .git
$ git rm --cached .claude/settings.local.json fatal: pathspec '.claude/settings.local.json' did not match any files
The second one usually means the file was never tracked, which is good news, or that you are in a subdirectory and the path is relative to the repository root. git rev-parse --show-toplevel settles which.
git rev-parse --show-toplevel
The approval flow is additive: it writes what you allowed. It has no notion of what should never be approved, so the deny list is where you put the boundary once and stop thinking about it.
{ "permissions": { "deny": [ "Read(./.env)", "Read(./.env.*)", "Read(./.claude/settings.local.json)", "Bash(curl *Authorization:*)", "Bash(export *TOKEN*)" ] } }
The reference documents what a deny rule does, in terms that make it the right home for this: it is for "files that hold API keys, secrets, or environment values", and Claude Code "excludes matching files from file discovery and search results, denies reads of them, and blocks the Edit and Write tools on the matching paths", including file commands it recognises in Bash such as cat, head, tail, sed and tee [5]. The first two entries are Anthropic's example, verbatim [1]. The third stops the agent reading the file it writes approvals into, which is one way a key ends up in a transcript. The last two are belt and braces on the command pattern that creates the problem, and they are worth testing rather than trusting: run the command once and confirm you get a denial instead of a prompt.
cat
head
tail
sed
tee
Put these in .claude/settings.json and commit them, because shared rules are the point of that file. The local file stays local and ignored.
For a repository you already run an agent in, in order:
git rm --cached
If you run agents for other people, the same five minutes applies to their repositories, and the answer is usually that nobody has looked. The same review gap shows up in agents chasing closed work and in agents nobody approved. The check costs nothing and the failure is silent by design: the file is ignored in the case where the tool made it, so the habit of not looking is exactly what the default encourages.
The business side of this is worth stating once, because it is the same failure with a different currency: an agent doing work nobody asked for, and an agent carrying a credential nobody meant to publish, are both the cost of an approval that happened once and was never reviewed. We wrote that version up in the post on what unrequested agent work costs, and the review habits that catch both are in the piece on who approves what an agent may do.
If you would rather have this done across your repositories and your team's habits, send a written intake describing where agents run and who owns the settings files. We run this as an embedded function for small teams, and we publish what we learn, including the accidents in our own repository.
[1] Anthropic, Claude Code settings, fetched 2026-11-09. Source of the approval-to-allow-rule behaviour, the "keeps it out of git when it creates the file" table row, the worktree and subdirectory rules, the pre-v2.1.211 starting-directory behaviour, the trusted-versus-untracked distinction, the settings JSON example with the Read(./.env) deny, and the Settings Error and Settings Warning definitions.
Read(./.env)
[2] Anthropic, Claude Code permissions, fetched 2026-11-09. Rule syntax for tool and path patterns.
[3] GitHub Docs, about secret scanning, fetched 2026-11-09. Quoted for which repository types get secret scanning automatically.
[4] anthropics/claude-code, the CLI repository and its changelog, for version numbering.
[5] Anthropic, Claude Code settings reference, fetched 2026-11-09. Source of the documented purpose of permissions.deny, and of the rule that a tracked .claude/settings.local.json is treated as repository settings.
permissions.deny
[6] Hacker News comment by bcherny, 2026-11-07T21:21:55Z, on the thread about this behaviour. Quoted for the statement that prototypes come before a shipped fix.
Maxpertise is an AI-native engineering company. We embed native AI engineers inside your team, live in about 10 days. Please enable JavaScript to view the site, or email contact@maxpertise.net.