[BUG] Windows drive-letter case not canonicalized in project keys — duplicate .claude.json / installed_plugins.json entries; VS Code trust silently dropped

Status Open
Reported on v2.1.204
Maintainer reply None cached
Activity 7 comments · opened Jul 8, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

On Windows, project paths are used as object keys in ~/.claude.json (projects) and in ~/.claude/plugins/installed_plugins.json (projectPath / installPath), but the drive letter is not case-canonicalized before the key is stored or compared. The stored case depends entirely on which surface launched the process:

  • VS Code extension spawns the CLI with cwd: workspaceFolder.uri.fsPath, and VS Code's fsPath lowercases the drive letterc:\Users\...
  • git-bash / MINGW / Cygwin paths (/c/..., /cygdrive/c/...) are run through a POSIX→Windows translator that uppercases the drive → C:\Users\...
  • PowerShell / cmd already report uppercase C:\...

Because lookups are case-sensitive (projects?.[key] and projectPath === o), the same physical directory ends up stored under two different keys (c:/... and C:/...). Windows paths are case-insensitive, so these are the same directory — but Claude treats them as distinct.

Two concrete user-visible symptoms:

  1. Workspace trust silently dropped in VS Code. I accepted the trust dialog (my ~/.claude.json contains "C:/Users/.../api-specs": { hasTrustDialogAccepted: true } and "C:\\Users\\...\\api-specs": { ... }). But the VS Code extension computes the key c:/Users/.../api-specs (lowercase, from fsPath), which doesn't match, so trust reads as false and 32 permissions.allow entries from .claude/settings.json were dropped. The error even says "workspace not yet trusted" when in fact it was trusted twice — under a different-cased key.
  1. Duplicate / mixed-case entries in installed_plugins.json. projectPath and installPath are stored raw (native backslashes, original case, never normalized), so the same file shows C:\\Users\\... on one line and c:\\Users\\... on the next. Case-sensitive === lookups then miss, causing duplicate entries / re-installs.

What Should Happen?

The drive letter (and ideally the whole path) should be canonicalized to a single case before being used as a stored key or in any equality comparison, so that one physical directory maps to exactly one entry regardless of launch surface (VS Code extension, git-bash, PowerShell, cmd).

Concretely: a workspace trusted once should stay trusted when opened from the VS Code extension, and a plugin installed once should not be duplicated/re-installed when the path case differs.

Error Messages/Logs

2026-07-08T19:36:36.105Z [DEBUG] Dropped 32 project-scoped permissions.allow entries — workspace not yet trusted
Ignoring 32 permissions.allow entries from .claude/settings.json: this workspace has not been trusted. Run Claude Code interactively here once and accept the trust dialog, or set projects["c:/Users/<user>/repos/api-specs"].hasTrustDialogAccepted: true in ~/.claude.json


Note the error references lowercase `c:/Users/...` (from VS Code `fsPath`), while `~/.claude.json` already contains a trusted entry keyed with uppercase `C:/Users/...`.

Steps to Reproduce

Root cause (from decompiled claude.exe) — the path-key normalizer folds separators but not drive case:

function doe(e){
  let t = normalize(e);
  if (platform === "windows") return t.replaceAll("\\", "/");  // separators only — drive case untouched
  return t;
}

Trust reader and writer both funnel through doe(), so they agree with each other — but doe()'s output is not canonical, so a different-cased cwd yields a different key:

// writer
function WIt(e){ let t = doe(path.resolve(e)); /* set projects[t].hasTrustDialogAccepted = true */ }
// reader
function Rse(){ let e = doe(...cwd...); return config.projects?.[e]?.hasTrustDialogAccepted === true; }

The case divergence comes from a POSIX→Windows translator that uppercases the drive, which only fires for POSIX-shaped input (git-bash/Cygwin) and not for native fsPath:

// /cygdrive/c/... and /c/... → C:\...   (uppercased)
// c:\... (VS Code fsPath) passes through unchanged → stays lowercase

Reproduction:

  1. On Windows, open a project in a terminal where the drive is uppercase (PowerShell, cmd, or git-bash — git-bash uppercases via the translator). Run claude, accept the trust dialog. → ~/.claude.json now has projects["C:/Users/you/proj"].hasTrustDialogAccepted = true.
  2. Open the same folder with the Claude Code VS Code extension (which spawns the CLI with cwd = uri.fsPath, lowercasing the drive to c:\...).
  3. Observe: the extension reports the workspace as untrusted and drops the project-scoped permissions.allow entries from .claude/settings.json, even though it was trusted in step 1. The error cites projects["c:/Users/you/proj"] (lowercase) — a different key than what was stored.

Plugins variant:

  1. Install a plugin scoped to a project from a lowercase-drive context (VS Code) → installed_plugins.json stores projectPath: "c:\\Users\\...".
  2. Inspect installed_plugins.json after any operation from an uppercase-drive context → you'll see mixed C:\\... / c:\\... entries (installPath upper, projectPath lower in the same file), and case-sensitive lookups (l.projectPath === o) miss, producing duplicates.

Suggested fix: canonicalize the drive letter (and folded case) inside doe(), e.g. t.replace(/^([a-z]):/, (_,d) => d.toUpperCase() + ":"), and route plugin projectPath/installPath storage + comparison through the same canonicalizer instead of storing/comparing raw strings. Since trust read+write both go through doe(), that single choke point fixes the trust duplication; the plugins file needs the same normalization applied at its store/compare sites (it currently bypasses doe() entirely). related #74912 #74612

Claude Model

Not sure / Multiple models

Is this a regression?

I don't know

Last Working Version

n/a

Claude Code Version

2.1.204

Platform

AWS Bedrock

Operating System

Windows

Terminal/Shell

VS Code integrated terminal

Additional Information

(Also reproducible cross-surface: the mismatch specifically arises between the VS Code extension's fsPath cwd and git-bash/PowerShell launches.)

---

Additional Information

  • The bug spans multiple config files, so a fix should target the shared path-key normalization rather than any single call site:
  • ~/.claude.jsonprojects object keys (trust, permissions.allow, onboarding state)
  • ~/.claude/plugins/installed_plugins.jsonprojectPath / installPath (stored raw, backslashes + original case)
  • The whole binary has no drive-letter case-folding anywhere on these paths (grep for normalizeDriveLetter / drive case-fold → none); doe() only swaps \/.
  • The user-facing error message is misleading: it says "workspace not yet trusted" when the workspace was trusted, just under a differently-cased key. If detectable, it would help to say "trusted under a different-cased path" and point at the case mismatch.
  • The VS Code extension itself contains no trust-handling logic (no hasTrustDialogAccepted / workspaceTrust references in extension.js); it fully delegates to the CLI's cwd-derived key — so the only reliable user workaround today is hand-adding the lowercase-drive key to ~/.claude.json, which is not discoverable from the extension UI.

related #67749 but my issue is based on Workspace Trust and Plugin loading, not MCPs, specifically.
related #69066
area:cli
area:vscode
area:plugins

View original on GitHub ↗

5 Comments

jeremyfiel · 1 month ago

@dflor003 any chance you can verify if this regression was also from 2.1.181 with the tests you ran? Thanks.

dflor003 · 1 month ago
@dflor003 any chance you can verify if this regression was also from 2.1.181 with the tests you ran? Thanks.

Yes, I can confirm that it broke between 2.1.179 and 2.1.181. I was able to fix it by downgrading to 2.1.179.

normancates · 1 month ago

Independent confirmation on Windows 11 + VS Code extension 2.1.214, with one
finding that I think refines the model in this issue.

The casing is not fixed per launch surface — it oscillates within a single
session.

This issue (and #76994) describe the stored case as determined by whichever
surface launched the process: CLI/git-bash → uppercase, VS Code fsPath
lowercase. That's correct as far as it goes, but it implies one process yields
one casing. It doesn't. A single VS Code session records both, alternating
throughout.

One ~6-hour session, cwd values across its transcript:

'w:\'        680 records   05:04:43 -> 10:54:43
'W:\'        346 records   05:06:32 -> 10:55:15
'W:\scripts'  50 records

Fully interleaved — 78 transitions between casings, each correlating with a
shell tool call:

'w:\' -> 'W:\'   after tool=Bash
'W:\' -> 'w:\'   after tool=Bash
...
total transitions: 78

Controlled test. Measured live, using non-shell tools to take the
measurements so the act of measuring didn't perturb the result:

  1. Baseline in the session transcript: "cwd":"w: = 102, "cwd":"W: = 61
  2. Ran a single PowerShell call, no Bash involved: (Get-Location).Path

→ returned W:\

  1. Re-counted: "cwd":"W: = 63

So it isn't specific to the git-bash POSIX translator named in this issue —
PowerShell does it too. The extension supplies a lowercase baseline, and any
shell tool invocation reports the true on-disk casing back.

Why this matters for the fix: canonicalizing at process start is not
sufficient. A user who only ever opens VS Code and never touches the CLI still
generates both keys, because the divergence happens mid-session, between the
session baseline and the shell layer. The cwd needs to be resolved once and
used everywhere, in addition to canonicalizing before keying.

Control group — sessions that never acquired a lowercase baseline are
uniformly single-cased, which is consistent with the mechanism:

UPPER  {'S:\dev\repos\repo-a': 8}
UPPER  {'S:\dev\repos\repo-a': 135}
UPPER  {'C:\WINDOWS\system32': 22}

Not drive-type dependent. Several related issues (#62288, #63527, #76205)
frame this around mapped network drives. On this machine the oscillation happens
across all three drive types, so mapping is not a factor:

| drive | Win32_LogicalDisk DriveType | filesystem | oscillates |
|---|---|---|---|
| mapped SMB share | 4 (network) | NTFS | yes |
| internal disk | 3 (local) | NTFS | yes |
| cloud-sync client mount | 3 (local) | FAT32 | yes |

The heaviest oscillation I recorded is on a plain local NTFS drive
(s:\dev\repos\repo-a 373 records vs S:\dev\repos\repo-a 2348), not on the
network mount. Worth noting because the mapped-drive framing in those issues may
be incidental — the reporters happened to be on mapped drives, but the casing
divergence does not require one.

Scale, on one machine. 4 of 12 keys in ~/.claude.json projects are
case-collision duplicates — 8 of 12 entries are two halves of one folder:

COLLISION: ['s:/dev/repos/repo-a', 'S:/dev/repos/repo-a']
COLLISION: ['S:/dev/repos/repo-b', 's:/dev/repos/repo-b']
COLLISION: ['S:/dev/repos/group-x/repo-c', 's:/dev/repos/group-x/repo-c']
COLLISION: ['W:/', 'w:/']

(Paths anonymized; drive letters, casing and depth preserved exactly.)

Longevity. Casing per session from a local session log, 2026-03 to 2026-07:

month      UPPER    lower
2026-03    6        0
2026-04    5        4
2026-05    8        6
2026-06    22       6
2026-07    9        7

First lowercase session on record: 2026-04-06. Six distinct workspaces recorded
under both casings.

One symptom worth adding: besides the trust/permission drops described here,
this also hides session history. Transcripts for both identities land in the
same on-disk directory (Windows collapses the w--/W-- slugs), so the
transcript is present and correctly titled in the very directory the picker
reads — but isn't offered, because the lookup is scoped by the case-sensitive
project key. That's #62288, same root cause.

Field-level divergence on the pair above:

| field | W:/ | w:/ |
|---|---|---|
| hasTrustDialogAccepted | True | False |
| hasCompletedProjectOnboarding | True | absent |
| projectOnboardingSeenCount | 1 | 0 |
| all 16 last* telemetry fields | present | absent |

One negative result, for completeness: allowedTools and mcpServers were
empty on both entries in my config, so I could not reproduce the MCP/
permission divergence from #67749 and #76994 here. Structurally possible, not
observed in my case.

rpdelaat · 1 month ago

Possible additional consequence of this same root cause: when the untrusted/wrong-cased project entry is hit in headless mode (claude -p, e.g. via Windows Task Scheduler), the effect isn't just dropped permissions.allow entries — MCP servers configured in the project's .mcp.json silently fail to load too, with no error, just missing tools. Reproduced consistently across two days of testing (local npx-spawned server + one HTTP server never loaded headlessly, despite working every time interactively from the same machine/config). See #63350 for the symptom from that angle — this issue looks like it may be the actual root cause.

karanjain-precisely · 1 month ago

Reproduced on Windows 11 + VS Code 1.131.0 + extension 2.1.220. I traced the extension-side
origin of the lowercase drive, which I think adds a second, independently fixable choke point
alongside the CLI-side doe() fix proposed here.

The extension does not pass fsPath straight through — it launders it through
fs.realpathSync, which is what silently preserves the lowercase drive.

This issue notes the extension spawns with cwd: workspaceFolder.uri.fsPath. That's right in
spirit, but the actual derivation in extension.js (2.1.220) is three structurally identical
sites:

let i = Tt.workspace.workspaceFolders?.map((a) => a.uri.fsPath) || [],
    o = fI.realpathSync(i[0] || mI.homedir()).normalize("NFC"),
    s = new go(this.context, o, this.settings, /* ... */);

The realpathSync call looks like it should canonicalize, and that may be why this has been
easy to miss — but on Windows it does not touch drive-letter case. Only
fs.realpathSync.native does, because it delegates to the OS canonicalization API. Measured
on Node v26.5.1:

const fs = require("fs");
const p = "c:\\Users\\<user>\\project";
fs.realpathSync(p);         // => 'c:\Users\<user>\project'   <-- lowercase preserved
fs.realpathSync.native(p);  // => 'C:\Users\<user>\project'   <-- canonical

o is then assigned to this.cwd on the session class and fans out to every
cwd: this.cwd consumer, including the process spawn. So one unnormalized value propagates
to the CLI, which then keys .claude.json off it.

One-line fix, at all three sites:

- fI.realpathSync(i[0] || mI.homedir()).normalize("NFC")
+ fI.realpathSync.native(i[0] || mI.homedir()).normalize("NFC")

Error semantics are unchanged — both variants throw ENOENT on a missing path — and
.normalize("NFC") still applies. As a bonus, .native canonicalizes every path component,
not just the drive.

Verified end to end. After patching the installed bundle and fully restarting VS Code (a
window reload is not enough — the extension host caches extension.js), a fresh session with
"entrypoint":"claude-vscode" recorded "cwd":"C:\\Users\\...", and no lowercase project key
was recreated when the CLI next rewrote ~/.claude.json. Before the patch, that same
panel-launched flow reliably produced the duplicate c:/... key. As a side effect,
ConvertFrom-Json can read ~/.claude.json again — with both keys present, PowerShell fails
outright with DuplicateKeysInJsonString, which breaks any user script that reads the config.

On @normancates' oscillation finding (both w:\ and W:\ within a single session): three
separate derivation sites in the extension may help explain it. They are reached by different
entry paths (resolveWebviewView, setupPanel, and a third panel-restore path), so which one
computes cwd depends on how a given view or panel was created or restored. Anything that
derives cwd outside these three sites would bypass the realpathSync step entirely and keep
whatever casing it started with. Worth checking whether the non-realpathSync paths are the
uppercase ones.

Note this fix is complementary rather than an alternative to canonicalizing inside doe().
The extension fix stops one producer of bad input; the doe() fix makes the CLI robust to
any producer, including claude invoked directly with an odd-cased cwd. Both seem worth
doing, and neither repairs config that is already split — an affected user still has two
project records on disk, so a one-time merge migration would be needed to fully close this out.

Showing cached comments. Read the full discussion on GitHub ↗