`.claude.json` stores one project directory under up to three different path spellings, splitting trust, MCP servers, and worktree state across them
Summary
On Windows, projects in .claude.json is keyed by a raw path string with no
normalization. Different writers produce different spellings of the same directory, so a
single project ends up stored as two or three independent records that never merge.
The records are not equivalent. The backslash-spelled one carries hasTrustDialogAccepted:; the forward-slash ones carry
truehasTrustDialogAccepted: false and are the only ones that
ever hold mcpServers, hasUnseenTeamArtifacts, and activeWorktreeSession.
The user-visible symptom is that a project appears to "lose" its configured MCP servers and its
active worktree, and re-prompts for trust it was already granted.
Environment
| | |
|---|---|
| OS | Windows 11 Pro 10.0.26200 |
| Claude Desktop | 1.25927.0 (Squirrel, %LOCALAPPDATA%\AnthropicClaude) |
| Config roots inspected | 5 independent ones, via CLAUDE_CONFIG_DIR |
Not related to #86012 (cross-session messaging) or #86556 (Squirrel roll-forward). This is a
data-model defect in .claude.json and reproduces with a single config root.
The three spellings
All three observed for one directory, in one config file:
c:/Users/<user>/Code/<project> lowercase drive, forward slash
C:/Users/<user>/Code/<project> uppercase drive, forward slash
C:\Users\<user>\Code\<project> uppercase drive, backslash
Evidence
Read-only. Run against your own config; it prints only key names and counts.
$f = Join-Path $(if ($env:CLAUDE_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR } else { "$HOME\.claude" }) '.claude.json'
$o = Get-Content $f -Raw | ConvertFrom-Json -AsHashtable
$byNorm = @{}
foreach ($k in $o.projects.Keys) {
$nk = $k.Replace('\','/').ToLowerInvariant()
if (-not $byNorm.ContainsKey($nk)) { $byNorm[$nk] = @() }
$byNorm[$nk] += $k
}
foreach ($nk in ($byNorm.Keys | Sort-Object)) {
if ($byNorm[$nk].Count -lt 2) { continue }
"=== $nk (stored $($byNorm[$nk].Count) times) ==="
foreach ($k in $byNorm[$nk]) {
$p = $o.projects[$k]
" {0,-46} trustAccepted={1,-6} mcpServers={2,-7} activeWorktreeSession={3}" -f `
$k,
$(if ($p.ContainsKey('hasTrustDialogAccepted')) { $p.hasTrustDialogAccepted } else { 'n/a' }),
$(if ($p.ContainsKey('mcpServers')) { @($p.mcpServers.Keys).Count } else { 'ABSENT' }),
$(if ($p.ContainsKey('activeWorktreeSession')) { 'YES' } else { 'absent' })
}
}
Output, one directory stored three times
=== c:/users/<user>/code/<project> (stored 3 times) ===
c:/Users/<user>/Code/<project> trustAccepted=False mcpServers=0 activeWorktreeSession=YES
C:\Users\<user>\Code\<project> trustAccepted=True mcpServers=ABSENT activeWorktreeSession=absent
C:/Users/<user>/Code/<project> trustAccepted=False mcpServers=0 activeWorktreeSession=YES
hasTrustDialogAccepted is true on exactly the spelling that has no mcpServers key, andfalse on the two that do.
The split is total, not a tendency
Across 5 independent config roots, 46 project keys, 7 colliding directories:
| key spelling | entries | carry mcpServers / hasUnseenTeamArtifacts / activeWorktreeSession |
|---|---|---|
| forward slash | 17 | 17 of 17 |
| backslash | 29 | 0 of 29 |
Every config root that has any backslash entry shows the same trust inversion: backslashtrue, forward-slash false, same directory.
Impact
- Trust state is not reliably keyed to a directory.
hasTrustDialogAcceptedis recorded
per spelling. A directory the user has already trusted reads as untrusted under a different
spelling of the same path. This is a security-relevant control keyed on an unnormalized
string, and it fails in both directions: a redundant prompt is merely annoying, but a trust
decision made for one spelling silently not applying to another is the direction that
matters, and neither is visible to the user.
- Configured MCP servers disappear.
mcpServersexists only on forward-slash entries. A
session resolving the backslash spelling sees none.
- Active worktree is lost.
activeWorktreeSessionexists only on forward-slash entries. A
session resolving the backslash spelling cannot tell which worktree it was working in. This
is the "my project lost its folders" symptom, and it is how the defect actually reaches a
user.
- Silent. Nothing warns that one directory holds multiple records. The file stays valid
JSON and the duplicate keys differ only by case and separator.
Note that ConvertFrom-Json (without -AsHashtable) throws on these files, becausec:/... and C:/... collide under .NET's case-insensitive property handling. Any tooling that
parses .claude.json case-insensitively will fail outright rather than degrade.
Expected behaviour
Normalize the project path before using it as a key — canonical separator and canonical drive
letter case on Windows — and migrate existing entries by merging colliding records. Failing
that, at minimum key trust state on a normalized path, since that one is a security control.
Not reproducible on demand
I cannot give steps that produce a second spelling, because I do not know which code path writes
which form. What I can give is that it is present in 5 of 5 config roots on this machine,
affecting 7 distinct directories, with a completely consistent signature.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Independent confirmation on two Windows machines, plus one data point on the "not reproducible on demand" question.
Environment
| | Machine A | Machine B |
|---|---|---|
| OS | Windows 10 | Windows 11 Pro 10.0.26200 |
| Claude Code | observed v2.1.114 → v2.1.237 | same |
| Config roots | 1 (no
CLAUDE_CONFIG_DIR) | 1 |Same signature you describe: the backslash-spelled key carries
hasTrustDialogAccepted: true, the forward-slash twin carriesfalse, same directory. The user-visible symptom was the trust dialog — "Quick safety check: is this a project you created or one you trust?" — re-appearing for folders used daily for months.Scale
| | project keys | real projects | duplicated |
|---|---|---|---|
| Machine A | 40 | 11 | every one |
| Machine B | 7 | 3 | 3 pairs |
On "Not reproducible on demand"
One observation that may narrow which code path writes which spelling. On Machine A, the only forward-slash entries that already carried
hasTrustDialogAccepted: truewere projects first opened after the spelling changed. Every project predating the change had a trusted backslash entry and an untrusted forward-slash twin:That is consistent with a new record being created at the new spelling rather than the existing record being migrated — i.e. the fork is produced by the write path itself, not by two writers racing on an already-forked file.
Workaround
Merges each backslash key into its forward-slash twin and carries the trust flag across. Claude must be fully closed — a running instance rewrites
.claude.jsonon exit and will undo this. Verify withGet-Process -Name claude; closing the window can leave a background process behind.-Depth 100is mandatory — PowerShell'sConvertTo-Jsondefaults to a depth of 2 and will silently flatten.claude.jsoninto unusable strings.Note this addresses the trust half only. It does not merge
mcpServersoractiveWorktreeSession, which as you found live on the forward-slash entries — in our case those were the spelling Claude Code was already reading, so carrying trust forward was sufficient.After the fix, every forward-slash key on both machines reports
hasTrustDialogAccepted: True, and launching in previously affected folders produces no prompt.Still relevant. On Windows,
projectsin.claude.jsonremains keyed by an unnormalized raw path string, so different writers produce different spellings of one directory and it ends up stored as two or three independent records that never merge.The records are not interchangeable, which is what turns a cosmetic duplication into a real defect: the backslash spelling carries
hasTrustDialogAccepted: true, while the forward-slash spellings carryfalseand are the only ones that ever holdmcpServersand the active worktree state. So trust lives on one record and the MCP servers live on another, for the same project.Path normalization on write, plus a merge for existing records, would close it.
The desktop reads and writes trust keys as backslash — yet the base repositories are stored forward-slash, where its lookup can never see them
Paths are anonymised, everything else is as measured:
<root>,<dir-1>,<dir-2>are container directories,<repo-a>…<repo-c>distinct repositories,<wt>a worktree name,<net>:a mapped network drive with\\<server>\<share>as its UNC target,<account>/<workspace>two GUID path segments the app inserts,<session>a session id.Windows 11 Enterprise 10.0.26200 · Claude Desktop 1.46388.4 · host CLI 2.1.260. Code verified in that build's
app.asar. Store and session-record counts measured 2026-09-06 15:36 (they drift by the minute); denial counts cover 2026-09-02 → 09-05; log-line counts are overmain1.log+main.log.Both directions normalise to backslash
Read side — the lookup, verbatim except for one gloss (
vz= realpath of the git root, falling back tohr(resolve(e))):Write side —
acceptTrustDialog, in full, because it writes up to two keys and that turns out to matter:path.win32.resolverewrites/to\(C:/x→C:\x; a lowercase drive letter stays lowercase), andhrnormalises only WSL UNC prefixes (/^[\\/]{2}(wsl\$|wsl\.localhost)/i). Both keys the dialog writes —rand the git-roota— pass throughhr(resolve(…))orhr(realpath(…)), so both are backslash; I checked that branch specifically because it looked like a candidate for producing the other spelling, and it is not. On the read side, strict branch, git-root realpath and ancestor walk are all backslash too. The normalising scan inyzexists but is gated bymr()to WSL paths, so it never fires for a drive-letter path.The store says otherwise
Counting only non-worktree keys that are actual git repositories: 14 are stored forward-slash (13 of them
hasTrustDialogAccepted: true) against exactly 1 backslash. (The raw non-worktree split is 27:2, but 11 of those 27 point at directories that no longer exist and 7 carryfalse, so the repository-restricted count is the honest comparison.)In this build the desktop dialog cannot have written those forward-slash keys. Something else did, and a repository trusted through that other path stays invisible to the desktop's check in every branch. Which writer produces the forward-slash base-repo keys the desktop can never read? The CLI is the obvious candidate; I cannot verify it from here — I only scanned the desktop bundle, not the CLI binary.
jjmgladden's comment above bears directly on this and is the best answer so far: the forward-slash entries that already carried
truewere projects first opened after the spelling changed, which fits "a new record is created at the new spelling rather than the existing one being migrated". My store is consistent with that and adds the read side — whoever creates those records, the desktop lookup cannot reach them.What that costs, measured
Between 2026-09-02 and 09-05 the app log holds 26
is not trusted (session_start)denials — 24 naming a backslash path, 2 a forward-slash one. A watcher I run samples~/.claude.jsonat the instant a denial is logged; it captured 23 of the 26 (the three it missed all predate its restart on 09-02 21:18 — a coverage gap in time, not a selection). In 23 of 23 the base repository was present only as a forward-slash key withtrue, its backslash twin absent. One record, reformatted;base repois derived by my tool from the logged worktree path, not read from a field:The second line matters: the store does hold the backslash form for the worktree in the same tree. It is the base-repo key — the one
originCwdresolves to — that is missing.Three things against my own argument:
realpathresolving the drive letter to its UNC target and missing that way — I tested and can rule out:fs.realpathSync('<net>:/…')returns<net>:\…, no UNC resolution.A second consumer fails far more often than
session_start: in the same window the log holds 169Skipping warm for <session> — workspace no longer trustedlines against those 26 denials.Why it is intermittent
The desktop does write backslash base-repo keys, and they do not survive. In one log window a backslash base-repo key was written for 8 spellings of 7 directories; none of those 8 is in the store today. After
2026-09-02 15:02:20 Saved workspace trust for C:\<dir-1>\<repo-b>both spellings were present andtrueacross 13 distinct file versions over four minutes, and hours later only the forward-slash one remained..claude.jsonis rewritten frequently under this workload.That is also the one colliding pair I ever caught, which bears on the polarity question in this thread: both entries were
true, not backslash-true/forward-false. Otherwise my store has held no colliding pair at any sampling point, so that is one observation, not a test.Two more stores carrying the same unnormalised string
Session records —
%APPDATA%\Claude\claude-code-sessions\<account>\<workspace>\local_*.jsonkeeporiginCwdandcwdraw. Across 448 records: 21 distinct values for 14 normalised directories, 6 directories under more than one spelling, one under three (C:\…,C:/…,c:/…); one pair differs by segment case alone, another by case and separator. 14 worktrees hold records carrying both spellings of their base repository (this count drifts as sessions come and go). These do not decide the denial —resolve()flattens them — but they are the same defect a layer up, and they feedoriginCwd. (#86181 reports this store from the folder-picker side.)Worktree registry —
git-worktrees.json: 50 entries, 12baseRepovalues for 8 normalised directories, 4 dual-spelled.Log lines, where the third spelling from the issue body appears: of 190
Saved workspace trust for …lines, 73 name a forward-slash path — 5 with a lowercase drive letter — 101 a backslash base repo, 16 a backslash worktree path. These lines print the argument, not the key that was stored; afterhr(resolve())the stored key is backslash regardless. The same holds forAuto-trusted worktree with key "C:\<root>\<repo-a>\.claude\worktrees\<wt>" (inherited from C:/<root>/<repo-a>)— the written key comes from the worktree path, theinherited fromhalf is the session's rawcwd; 12 of 25 such lines show the two spellings side by side.One normalisation class not yet mentioned
The store holds
<net>:/<dir-2>/<repo-c>and\\<server>\<share>\<dir-2>\<repo-c>, bothtrue— one directory under a mapped drive letter and under its UNC target, whichrealpathdoes not merge. Worth reporting; I would not auto-merge it into a trust decision, because a drive letter is a mutable alias and may point elsewhere tomorrow.Expected behaviour
yz— it is only gated bymr()to WSL paths. Letting it also compare separator-normalised drive-letter paths would make trust granted under either spelling readable, without touching a single stored record.cwdwhile the lookup usedoriginCwd; for a worktree session those differ, and that alone cost me days.Read-only check for the session store
Group-ObjectandSort-Object -Uniqueare case-insensitive by default, which hides exactly the case-only duplicates — hence-CaseSensitive:Related: #72640 · #86181 · #84388 (same outcome for dispatch-spawned CLI sessions, attributed there to a forward-slash twin carrying
false; here the twin is absent rather thanfalse) · #90041, which is worth reading alongside this but points the other way: its A/B test shows aheadersHelpertrust gate that looks the project up with forward slashes and blocks on the backslash key — the mirror image ofqvnabove. Whether that is a different code path or the same one under different conditions, I have not established; if the polarity really differs per consumer, a fix that only normalises one of them will move the failure rather than remove it.