`.claude.json` stores one project directory under up to three different path spellings, splitting trust, MCP servers, and worktree state across them

Status Open
Maintainer reply None cached
Activity 7 comments · opened Aug 21, 2026

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:
true
; the forward-slash ones carry hasTrustDialogAccepted: 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, and
false 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: backslash
true, forward-slash false, same directory.

Impact

  1. Trust state is not reliably keyed to a directory. hasTrustDialogAccepted is 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.

  1. Configured MCP servers disappear. mcpServers exists only on forward-slash entries. A

session resolving the backslash spelling sees none.

  1. Active worktree is lost. activeWorktreeSession exists 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.

  1. 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, because
c:/... 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.

View original on GitHub ↗

3 Comments

jjmgladden · 1 month ago

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 carries false, 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: true were projects first opened after the spelling changed. Every project predating the change had a trusted backslash entry and an untrusted forward-slash twin:

Sep   Trust  Path
BACK  True   C:\AI\Claude\MedLog
FWD   False  C:/AI/Claude/MedLog
BACK  True   C:\AI\Claude\MakerPipe
FWD   True   C:/AI/Claude/MakerPipe      <- first opened after the change

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.json on exit and will undo this. Verify with Get-Process -Name claude; closing the window can leave a background process behind.

$p = "$env:USERPROFILE\.claude.json"
Copy-Item -LiteralPath $p -Destination "$p.bak" -Force
$j = Get-Content -LiteralPath $p -Raw | ConvertFrom-Json
foreach ($prop in @($j.projects.PSObject.Properties)) {
    $back = $prop.Name
    if ($back -notlike '*\*') { continue }
    $fwd     = $back.Replace('\','/')
    $dstProp = $j.projects.PSObject.Properties[$fwd]
    if (-not $dstProp) {
        $j.projects | Add-Member -NotePropertyName $fwd -NotePropertyValue $prop.Value -Force
        continue
    }
    $dst = $dstProp.Value
    if ($dst.PSObject.Properties['hasTrustDialogAccepted']) {
        if (-not $dst.hasTrustDialogAccepted) { $dst.hasTrustDialogAccepted = $true }
    } else {
        $dst | Add-Member -NotePropertyName hasTrustDialogAccepted -NotePropertyValue $true -Force
    }
}
$j | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $p -Encoding utf8

-Depth 100 is mandatory — PowerShell's ConvertTo-Json defaults to a depth of 2 and will silently flatten .claude.json into unusable strings.

Note this addresses the trust half only. It does not merge mcpServers or activeWorktreeSession, 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.

wshallwshall · 1 month ago

Still relevant. On Windows, projects in .claude.json remains 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 carry false and are the only ones that ever hold mcpServers and 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.

cheruu2 · 1 month ago

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 over main1.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 to hr(resolve(e))):

async function qvn(e, t, r) {
  if (!isAbsolute(t)) return yz(e.projects, t)?.hasTrustDialogAccepted === !0;
  let i = hr(resolve(t));
  if (r) return yz(e.projects, i)?.hasTrustDialogAccepted === !0;   // strict branch
  let a = await vz(t);
  if (yz(e.projects, a)?.hasTrustDialogAccepted) return !0;
  for (; i !== null; ) {                                            // ancestor walk
    if (yz(e.projects, i)?.hasTrustDialogAccepted) return !0;
    let p = resolve(i, ".."); i = p === i ? null : p;
  }
  return !1;
}
function yz(e, t) {
  if (!e) return; let n = e[t];
  if (n || !mr(t)) return n;
  for (let [k, v] of Object.entries(e)) if (mr(k) && hr(k) === t) return v;   // normalising scan
}
function vz(e) {
  if (!isAbsolute(e)) return e;
  try { let t = await Ygn(e); if (t) return hr(await realpath(t)) } catch {}
  return hr(resolve(e));
}

Write side — acceptTrustDialog, in full, because it writes up to two keys and that turns out to matter:

async function Xvn(e, t) {
  let r = isAbsolute(e) ? hr(resolve(e)) : e, i = [r],
      a = t?.literalKeyOnly ? r : await vz(e);
  if (a !== r) try { … i.push(a) } catch {}
  await zvn(i, (x => ({ ...x, hasTrustDialogAccepted: !0 })));
}

path.win32.resolve rewrites / to \ (C:/x → C:\x; a lowercase drive letter stays lowercase), and hr normalises only WSL UNC prefixes (/^[\\/]{2}(wsl\$|wsl\.localhost)/i). Both keys the dialog writes — r and the git-root a — pass through hr(resolve(…)) or hr(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 in yz exists but is gated by mr() 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 carry false, 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 true were 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.json at 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 with true, its backslash twin absent. One record, reformatted; base repo is derived by my tool from the logged worktree path, not read from a field:

denial (log)  2026-09-05 00:18:55
  logged path (= session cwd)  <net>:\<dir-2>\<repo-c>\.claude\worktrees\<wt>
  derived base repo            <net>:\<dir-2>\<repo-c>    ← what the lookup resolves to
  store, same instant          <net>:/<dir-2>/<repo-c>                  forward = true
                               <net>:/<dir-2>/<repo-c>/.claude/worktrees/<wt>   backslash = true

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 originCwd resolves to — that is missing.

Three things against my own argument:

  • 21 of the 26 denials target one mapped network drive, 3 a local repository and 2 another. That concentration could be a confound. The obvious alternative — realpath resolving 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.
  • My instrument's verdict field is wrong. It labels these records "key present and trusted, contradiction", a judgement written against an older build on the assumption that the lookup normalises to forward slash. Given the code above that assumption is false; read the label only as "the forward-slash twin was present".
  • Two of 21 worktree key rows in those records are forward-only, against 19 backslash — so "the desktop writes backslash" has exceptions in my own data that I cannot explain.

A second consumer fails far more often than session_start: in the same window the log holds 169 Skipping warm for <session> — workspace no longer trusted lines 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 and true across 13 distinct file versions over four minutes, and hours later only the forward-slash one remained. .claude.json is 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_*.json keep originCwd and cwd raw. 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 feed originCwd. (#86181 reports this store from the folder-picker side.)

Worktree registry — git-worktrees.json: 50 entries, 12 baseRepo values 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; after hr(resolve()) the stored key is backslash regardless. The same holds for Auto-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, the inherited from half is the session's raw cwd; 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>, both true — one directory under a mapped drive letter and under its UNC target, which realpath does 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
  • Smallest fix: the normalising scan already exists in yz — it is only gated by mr() 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.
  • Log the key that was actually checked, not only the reported path. Today the error names the session cwd while the lookup used originCwd; for a worktree session those differ, and that alone cost me days.
  • Then the write-side question above: whichever component writes forward-slash base-repo keys should agree with the desktop, or a migration will re-fork.
  • Keep the mapped-drive/UNC class out of any automatic merge.
Read-only check for the session store

Group-Object and Sort-Object -Unique are case-insensitive by default, which hides exactly the case-only duplicates — hence -CaseSensitive:

$root = "$env:APPDATA\Claude\claude-code-sessions"
$keys = Get-ChildItem $root -Recurse -Filter local_*.json | ForEach-Object {
  $j = Get-Content $_.FullName -Raw | ConvertFrom-Json
  if ($j.originCwd) { $j.originCwd } else { $j.cwd }
}
$keys | Group-Object { $_.Replace('\','/').ToLowerInvariant().TrimEnd('/') } |
  Where-Object { ($_.Group | Sort-Object -Unique -CaseSensitive).Count -gt 1 } |
  ForEach-Object {
    "=== $(($_.Group | Sort-Object -Unique -CaseSensitive).Count) spellings, $($_.Count) records ==="
    $_.Group | Group-Object -CaseSensitive | ForEach-Object { "   {0,5}x  {1}" -f $_.Count, $_.Name }
  }

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 than false) · #90041, which is worth reading alongside this but points the other way: its A/B test shows a headersHelper trust gate that looks the project up with forward slashes and blocks on the backslash key — the mirror image of qvn above. 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.

Showing cached comments. Read the full discussion on GitHub ↗