This guide details how to find and remove secrets that were inadvertently committed to projects and repositories hosted on GitLab. We’ve confirmed that GitLab’s own infrastructure secrets are protected.
In Claude Code, Anthropic’s coding agent, a developer can approve a shell command by selecting “Yes, and don’t ask again.” Claude then saves that exact command to .claude/settings.local.json in the project folder and runs it without asking in the future. If the command contained a password or a token, so does the file:
{
"permissions": {
"allow": [
"Bash(npm test:*)",
"Bash(curl --header \"PRIVATE-TOKEN: glpat-EXAMPLEdummyTOKEN1234\" \"https://gitlab.com/api/v4/projects/1234567/issues?state=closed\")",
]
}
}
Claude Code keeps the file out of Git through the developer's personal global excludes, not through the project's .gitignore. That protection lives on one laptop and does not travel with the repository, so the file gets committed and pushed wherever the repository lives. In early November 2026, Apiiro reported that this issue resulted in 104,185 copies of the file to appear in public repositories, 1,122 of them holding at least one secret. Hundreds more secrets were only in history, in files that had since been deleted.
If your developers use Claude Code with repositories hosted on GitLab, this guide shows how to prevent this from happening moving forward, assess exposure now or in the past, and clean every affected project up at once.
Prevent: Keep the file out of your repo
There are a few actions you can perform to ensure a developer cannot commit the file by accident, and that if one slips through, you hear about it. There are four layers you can put in place, each a little more work than the last, and the order below is a reasonable one to follow. All of them are settings a group owner changes once; this is the one part of the process GitLab Duo can walk you through but cannot do for you, since its access to GitLab settings is read-only.
Start with .gitignore
The simplest fix is also the one Claude Code should have done for you but does not: a line in each repository's .gitignore that reads .claude/settings.local.json. Claude Code only ever adds the exclusion to the developer's own global git configuration, which stays on that laptop.
If you're on Premium or Ultimate, put the same line into the .gitignore in your group's file templates project (in the group, go to Settings > General > Templates), so that every project created from now on inherits it without anyone having to remember.
Block the file name with a push rule (Premium and Ultimate)
A push rule turns the convention into something GitLab enforces. In the group, go to Settings > Repository > Pre-defined push rules and, under Prohibited filenames, enter:
(^|/)\.claude/settings\.local\.json$
From then on, any push that tries to add that file, in the root of the repository or in any subdirectory, on any branch, is rejected before it lands, and the developer sees a message naming the pattern.
There are two things to know about how push rules behave, both of which matter here. The rule only checks files being added, not changes to a file that is already tracked, which is why cleaning up the existing copies has to come first. And a rule set on the group is copied into projects created after that moment; it does not reach back to projects that already exist. For those, you set the same rule on each project, either from the project's own Settings > Repository > Push rules page or, if you have more than a handful, with a loop over the group's project ids:
POST /api/v4/projects/<id>/push_rule {"file_name_regex": "(^|/)\\.claude/settings\\.local\\.json$"}
For a project that already has push rules of its own, use PUT on the same path instead; POST will answer 422 Project push rule exists.
Add Secret Push Protection for the tokens (Ultimate)
The push rule stops the file by name. Secret Push Protection looks at the content of every push, whatever the file is called, and rejects it if it finds a known token format. On GitLab Self-Managed and Dedicated, you first allow the feature for the instance under Admin > Settings > Security and compliance; on GitLab.com, that part is already done. Then you turn it on for each project under Secure > Security configuration > Secret push protection, or for a whole group at once through the group security settings API.
Scan everything with one policy (Ultimate)
A scan execution policy on the top-level group runs GitLab Secret Detection in every project, including ones that have no .gitlab-ci.yml at all, and individual projects can't switch it off. Create it under Group > Secure > Policies > New policy > Scan execution policy, with one rule that runs on every pipeline and a second that runs on a weekly schedule. The scheduled run looks through history the first time it runs; add the variable SECRET_DETECTION_HISTORIC_SCAN: 'true' to that rule if you want every weekly run to cover full history rather than only new commits. It detects the same token formats as push protection and also covers what is already in the repository, including history when the historic scan is on.
Assess: Do you have the file?
Once you’ve put the preventative measures in place, you want to know whether .claude/settings.local.json exists anywhere in your repositories, either now or at some point in the past. What the file contains only matters once you have the list of affected repositories, and there are a few ways to get it depending on what your GitLab subscription includes.
Ask GitLab Duo
If your group has GitLab Duo (Duo Core on Premium and Ultimate, or GitLab Credits on a Free group on GitLab.com), the quickest route is to open GitLab Duo Agentic Chat, part of the GitLab Duo Agent Platform, in your top-level group and paste this:
Has any project in the <GROUP> group ever had a file named .claude/settings.local.json, including projects where the file was later deleted and projects where it only exists on a branch other than the default? For each project, tell me whether it has the file now, and if not, the commit where it was added and the commit where it was removed. Explain how you checked.
GitLab Duo works through the group and its subgroups, queries each project's commit history for that exact path, lists the branches of any project with a hit, and fetches the current file to distinguish a copy that is still there from one that was deleted.
What comes back is a single table: the project, whether the file is present now, the commits that added and removed it, and a note when a deleted version still held a secret. Forks inside the group are covered along the way, because each fork is a project with its own history, and GitLab Duo finishes by stating what it did not check, such as projects outside the group. Because it reads history rather than a search index, a group of a hundred or so projects takes several minutes, and that same thoroughness is why it finds the deleted copies that a search cannot.
If your group has GitLab Orbit, GitLab Duo may use that knowledge graph instead of the search index for the current copies, which brings that part of the answer back in about a minute, with the same results.
Search for it yourself (Premium and Ultimate)
Without GitLab Duo, the fastest way to find current copies is code search. In each top-level group, open Search, choose the Code scope and tick "Include archived". The query you type depends on which search engine your group runs, and the results page tells you which one that is. Where exact code search is active, which is the case on GitLab.com paid tiers and on Self-Managed instances with Zoekt, turn on regular expressions and search for:
file:\.claude/settings\.local\.json$
Where advanced search is active instead, which covers GitLab Dedicated and Self-Managed instances running Elasticsearch, the query is:
filename:settings.local.json path:.claude
Code search works in one top-level group at a time, including that group's subgroups. If you own several groups, the API makes the loop painless, and the filename: and path: form works there for both engines:
GET /api/v4/groups?owned=true&top_level_only=true&per_page=100
GET /api/v4/groups/<id>/search?scope=blobs&search=filename:settings.local.json%20path:.claude&exclude_forks=false&per_page=100
Keep the exclude_forks=false parameter, because the API leaves forks out otherwise. On a GitLab Self-Managed instance any signed-in user can skip the loop and search the whole instance with GET /api/v4/search?scope=blobs&search=filename:settings.local.json%20path:.claude.
Whichever engine you use, remember that search shows the default branch as it stands today. A file that was deleted, or that lives on another branch, is invisible to it, which is why the next check exists.
Check history (all tiers)
A file that was committed and then deleted is still a leak, so for every project you need to know whether the file ever existed on any branch. The commits API answers that, works on every tier and deployment type, and needs nothing to be enabled first:
GET /api/v4/groups/<id>/projects?include_subgroups=true&per_page=100
GET /api/v4/projects/<id>/repository/commits?path=.claude/settings.local.json&all=true&follow=false&per_page=100
If the second call returns commits, the content is in that project's history; if it returns an empty list, it never was. This is also the check that finds forks, since they are projects like any other.
One detail matters more than it looks: Do not drop follow=false. When all=true is combined with a path, GitLab walks through the newest commits of the whole repository first and only then filters by path, so a repository with more than a page of commits can come back empty even though the file is in its history. With follow=false, the walk is pruned by path from the start and the first page lists every commit that ever touched the file.
Check forks
Every fork of an affected project keeps its own copy of the file and its own history, so list the forks with GET /api/v4/projects/<id>/forks and repeat the checks above for each one.
Mitigate: Rotate, remove, purge
Whatever secrets you find, the order of operations is rotate, then remove, then purge. Treat every secret the file ever held as public from the date of the first commit that contained it, and rotate it at its provider before you touch the file at all. Deleting the file on its own achieves nothing: The value stays in the repository's history and in every fork until the secret it protects has been changed.
Remove the file everywhere at once (with GitLab Duo)
Once rotation is underway, open Agentic Chat in the top-level group and paste:
In the <GROUP> group, for every project that currently has a committed .claude/settings.local.json on its default branch, open a merge request that removes the file and adds .claude/settings.local.json to the project's .gitignore. Do not merge anything. Skip projects that already have such an open merge request. When done, give me one table: project, merge request link, and the kinds of secrets the file held (no values). Then tell me what remains for me to do across all of them.
GitLab Duo finds the affected projects (through Orbit where the group has it, otherwise through search), prepares one commit per project that deletes the file and adds the .gitignore entry. It then pauses twice for your approval: once for all the commits together and once for all the merge requests together, so cleaning up a whole group costs two clicks. When it is done, you get a table of projects, merge request links, and the kinds of secrets each file holds, with no values reproduced, followed by the list of what is still yours to do. All that remains is to review and merge the merge requests.
Without GitLab Duo, the same change in each project is three commands:
git rm --cached .claude/settings.local.json
echo '.claude/settings.local.json' >> .gitignore
git commit -m "Stop tracking Claude Code local settings"
Purge the content from history
Removing the file only takes it out of the current tree. The old versions stay in the repository until you rewrite history, so do this for every project where the file held a secret, after the secrets are rotated.
In the project, go to Settings > Repository > Repository maintenance > Remove blobs, paste the ids of the file's old versions (open the repository tree at an old commit to copy them), and confirm. Then run housekeeping, wait 30 minutes, and prune unreachable objects.
Check that it worked: the commit ids after the removed blob change, and the history check from Assess returns nothing for the path. If nothing has changed after a few minutes, check your email, because a failed rewrite is reported only there, to the person who started it.
Two cases need care. If the project is a fork, or a public project with forks, the shared object pool keeps the content reachable even after the rewrite; detach the forks first, or clean each repository on its own. And after any rewrite, open merge requests may need a rebase and everyone has to re-clone the repository.
Summary
The TL;DR of this guide is: Ask GitLab Duo whether you have the file, now or in the past. Rotate everything the files held. Ask GitLab Duo to open the clean-up merge requests across the group, review and merge them. Purge history where secrets were. Set the push rule, turn on push protection, add the policy. Two prompts in total, two approval clicks, and a few minutes of settings help ensure that your repositories stay free of this vulnerability.
Read more
- GitLab Duo Agentic Chat
- Secret Push Protection and Pipeline Secret Detection
- Push rules: prohibited file names
- Scan execution policies
- Remove blobs and repository maintenance






