anthropics / anthropics/claude-code

[BUG] No environment scoping on the shared ~/.claude state root: consent, config schema, transcript retention and permission defaults couple CLI and desktop sessions

Open
#94,866 0 comments 0 reactions 0 assignees View on GitHub
area:core area:desktop area:security bug platform:windows
Dominant language
Python
Stars
145k
Forks
23.1k
PR merge metrics
PR metrics pending

Description

### Preflight Checklist

- [x] I have searched [existing issues](https://github.com/anthropics/claude-code/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug) 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?

Measured on (2026-09-16, UTC):
- Claude Code CLI 2.1.273 — native install, auto-updates on
- Claude desktop app 1.52386.6 (MSIX), bundling Claude Code runtime 2.1.270 (2.1.266 still present beside it)
- Windows 11, build 26200
- one Windows user profile, one ~/.claude

Everything below describes these versions on that date; the shared root, the exemption in item C and the version skew in item D may already differ in a later release, so each item names the file and the code as of these versions for a maintainer to diff from this stamp rather than re-measure.

Claude Code's CLI (`claude`) and the Claude desktop app's bundled Code runtime are two separate binaries — on Windows commonly at two different versions — that both read and write one shared, unsandboxed state directory: `~/.claude/` and `~/.claude.json`. This is a genuinely new shape of program to reason about: long- lived and resumable across days rather than a one-shot command, capable of spawning further `claude` processes of its own, and holding live OAuth grants to a growing number of MCP servers rather than one vendor credential — closer to a browser profile (sessions, per-site permissions, many credentials) than to a classic CLI tool.

This report is not about whether the CLI and the desktop app should share that root — the alternative would mean signing in and re-approving every project twice, which would be worse. It's about six specific places, all on that one shared root, where the sharing currently produces behavior a person watching only one of the two surfaces would not expect.

Two related reports already cover part of the same underlying picture and are not re-reported here: #93671 (the desktop's `/mcp` OAuth flow sends a redirect URI type the target server rejects) and #89321 (an MCP re-authenticate clears the shared token store before the new sign-in completes, breaking concurrent sessions) — I've added corroborating reproductions as comments on both instead of filing again. #80635 and #87702 cover a related but separate mechanism, the MCP needs-auth cache having no scope key beyond the server name; I've added a third reproduction of that same gap as a comment there too. This issue is the rest of the picture — what's left once those three are set aside.

────────────────────────────────────────────────────────────────
ITEM A — Trust and consent flags in ~/.claude.json are not scoped by environment
────────────────────────────────────────────────────────────────

What's shared: ~/.claude.json holds, per project, `hasTrustDialogAccepted`, `hasClaudeMdExternalIncludesApproved`, `hasClaudeMdExternalIncludesWarningShown`, `allowedTools`, `enabledMcpjsonServers`/`disabledMcpjsonServers`, and `mcpServers`. Both the CLI and the desktop app read and write this file; nothing in the schema records which surface made a given decision.

Impact: approving a folder-trust dialog, or an external-includes warning, in one surface silently satisfies the same check for the other surface on the same project — including a headless `claude -p` script that never showed anyone a prompt. On a 36-project file measured here, 26 projects show `hasTrustDialogAccepted: true` and 7 show `hasClaudeMdExternalIncludesApproved: true`; nothing distinguishes a decision a person made by clicking the desktop app's dialog from one a terminal session made.

Confirmed live: opening a brand-new project in the desktop app and accepting its trust dialog wrote a `projects[]` entry straight into the shared file — `hasTrustDialogAccepted: true`, plus `hasClaudeMdExternalIncludesApproved: false`, `allowedTools: []`, `enabledMcpjsonServers: []`, `disabledMcpjsonServers: []` — with the app's own log (desktop app 1.52386.6) recording it as `Saved workspace trust for `. A `claude -p` script started in that same folder a moment later would read `hasTrustDialogAccepted: true` from a dialog it never showed anyone. Separately, opening — from the desktop app — a session that began in a terminal, in an unrelated project directory, re-triggers the desktop's own trust check for that session's directory. A session's trust state is re-evaluated per directory across the boundary; it isn't carried with the session itself.

What a user would expect: Firefox gives every installation channel (Stable, Beta, Developer Edition, Nightly) its own dedicated profile since version 67, specifically to prevent the kind of interference that comes from sharing state across differently-versioned installs, with an account as the explicit, opt-in way to sync specific data back together (https://support.mozilla.org/en-US/kb/dedicated-profiles-firefox-installation). VS Code Stable and Insiders likewise keep separate user-data directories and separate Settings Sync services by default. In both cases a shared decision is an opt-in choice at the account layer, not an implicit side effect of two surfaces reading one file.

Fix-owner: Anthropic — runtime code (the consent schema carries no origin field for either the CLI or the desktop host to write).

Minimal ask: record which surface made a security-relevant consent decision (even a single origin string per flag), so a decision made by one surface can prompt again on the other rather than silently satisfying it.

────────────────────────────────────────────────────────────────
ITEM B — ~/.claude.json mixes high-churn counters with that same consent state, and has already lost data once on the machine used for this report
────────────────────────────────────────────────────────────────

Not a fifth report of file corruption: #2593 (opened June 2025), #29105, #15608, and #29155 are four closed, locked reports of the concurrent-write corruption mechanic itself (atomic rename failing on Windows, a truncated file, a reset to defaults on parse failure) — none of them ever got a fix landed before being closed as stale or duplicate. This item is narrower: why the file is hit this often, and why the blast radius includes consent state when it is.

What's shared: ~/.claude.json (~100 KB, ~80 top-level keys, 36 projects here) is rewritten whole by both runtimes roughly every two minutes during normal use — 79 distinct whole-file rewrites sampled over 2 h 34 min. Most of those rewrites are pure telemetry: `pluginUsage..usageCount`/`lastUsedAt` (38–68 changes per plugin in the sample window), `cachedGrowthBookFeaturesAt`, `skillUsage.*`, and a 12-slot `clientDataCacheSlots` cache evicted on an insertion-order LRU. None of that needs to live in the same file as `hasTrustDialogAccepted`.

Evidence: this file has already lost data once here — a `~/.claude.json.backup` dated 2026-09-02, alongside the binary's own "Corrupted config backed up to:" recovery text (the same text #29105/#29155 describe; the string is present in the binaries named in the stamp, but which version wrote the 2026-09-02 backup was not recorded). Separately, a settings-schema migration already happened silently between the two runtimes sharing this machine: a `settings.json.bak-20260901` snapshot shows a newer runtime removing the key `autoUpdatesChannel` and adding `editorMode`, while the older runtime still bundled in the desktop app at the time kept reading the pre-migration shape (the versions performing that 2026-09-01 migration were not recorded either).

What a user would expect: Docker keeps its low-churn, credential-bearing client config (`~/.docker/config.json` — credential storage settings, credential helpers, contexts; confirmed at https://docs.docker.com/engine/reference/commandline/cli/) in a different location from the daemon's own high-churn state — images, containers, volumes and networks, which live under the daemon's separate data-root (`/var/lib/docker` by default on Linux, configured independently in `daemon.json`). The separation this item asks for already exists elsewhere in this same ecosystem.

Fix-owner: Anthropic — runtime code (the write-frequency design).

Minimal ask: move the high-churn counters (`pluginUsage`, `skillUsage`, `clientDataCacheSlots`, feature-flag caches) into their own file, so the file that decides whether a folder is trusted isn't rewritten whole every couple of minutes by two uncoordinated processes.

────────────────────────────────────────────────────────────────
ITEM C — the desktop transcript-retention exemption is inherited by any `claude -p` child process a desktop session spawns
────────────────────────────────────────────────────────────────

What's shared: ~/.claude/projects//.jsonl transcripts, and the retention sweep that prunes them after `cleanupPeriodDays` (default 30). Since 2.1.248 (changelog: "the transcript cleanup now keeps desktop-written sessions while they are in the app"), a transcript is exempted whenever its `entrypoint` field is `claude-desktop`, `claude-desktop-3p`, or `local-agent` — checked against the environment variable `CLAUDE_CODE_ENTRYPOINT` the process was started with, not against anything the desktop app's own session index still says about it. From the shipped binary — the same logic in both the desktop app's bundled 2.1.270 runtime and the CLI's own 2.1.273:

var r=new Set(["claude-desktop","claude-desktop-3p","local-agent"]);
function Qc(){let e=a.CLAUDE_CODE_ENTRYPOINT;return e!==void 0&&r.has(e)}

Impact: that check is a property of the process's environment, and environment variables are inherited by anything a process spawns. A `claude -p` command run from inside a desktop session's Bash tool carries `CLAUDE_CODE_ENTRYPOINT=claude-desktop` — because it is a child process of a desktop session — even though the person running it typed it into what is, from their point of view, an ordinary Bash command. Its own transcript then gets the same permanent retention exemption a real desktop chat gets, with no cap unless `desktopSessionCleanupPeriodDays` is set (default 0 = uncapped, read from the CLI 2.1.273 binary). Checked here against the desktop app's own live session list: of 366 top-level transcripts labelled `claude-desktop`, only 17 match a session the desktop app's own index currently lists — the other 349 correspond to no session it knows about, a mix of sessions already removed from that list and terminal commands a desktop session happened to run that were never a desktop chat at all. The same inherited variables also register token-refresh callbacks to a desktop host that is not listening, and change how an authentication failure is classified (treated as "wait for the host to refresh" instead of "stop and tell the user") for a process that has no host to wait for.

Confirmed live: deleting a session from the desktop app's own sidebar does not remove its transcript. The transcript (here, 4,594,393 bytes) stays exactly where it was; a 78-byte marker appears beside it instead — `.desktop-released.json`, holding `{"v":1,"releasedAt":"","reason":"delete"}` — and the retention sweep described above is what actually decides the file's fate from that point on, per `cleanupPeriodDays`. "Delete" in the sidebar is a release, not a deletion; nothing in the app tells the person who clicked it that the file is still on disk. Separately, opening — in the desktop app — a session that began in a terminal appends the desktop's own entrypoint to that same transcript alongside the terminal's: one file, two runtimes' handwriting, confirming live the cross-environment mixing the counts above were inferring from static totals.

#91464 already asks the retention exemption's exact trigger to be documented, reading it as ambiguous between "the app still lists this session" and "a property baked into the transcript." This item answers that from the process side: it is the latter, and the property is inherited by child processes, which #91464 does not cover. The opposite-direction failure of the same code path — desktop sessions being deleted when they should not be — is already tracked separately (#81100, #85466, #82084, #92825); this is not one of those.

What a user would expect: a command run inside an integrated terminal does not make the tool it runs believe it *is* the host application — running `git` inside an IDE's integrated terminal does not make `git` report the IDE's own version or apply the IDE's own settings to itself.

Fix-owner: Anthropic — runtime code (the exemption keyed on an inheritable label) and desktop host (the delete action's own labeling as a release, not a deletion).

Minimal ask: key the retention exemption off something the desktop app records at session creation (a marker, or a lookup against its own session index) rather than off an inheritable environment variable, or have a spawned shell child drop the host-identity variables (`CLAUDE_CODE_ENTRYPOINT`, `CLAUDE_CODE_SDK_HAS_*_REFRESH`, `CLAUDE_CODE_HOST_SESSION_ID`) before they or anything spawned further inherits them. Separately: since the sidebar's "Delete" already produces the `.desktop-released.json` marker rather than an actual delete, saying so in the UI (or naming the action "Remove from list") would cost nothing and would stop it from reading as data loss when it isn't.

────────────────────────────────────────────────────────────────
ITEM D — a plugin has no way to declare the runtime version it needs, and the two shared-cache consumers can be several minors apart
────────────────────────────────────────────────────────────────

What's shared: ~/.claude/plugins/cache//// and installed_plugins.json, read by both runtimes. Because the CLI self-updates and the desktop app's bundled runtime moves only with app releases, the two can sit several minor versions apart for extended periods — 2.1.270 vs 2.1.273 as of this writing, and the same three-minor gap on 2026-09-02 (bundled 2.1.255 against the CLI's 2.1.258 after that day's auto-update).

Impact: `plugin.json`'s manifest fields (`name`, `description`, `version`, `author`, `repository`, `license`, `keywords`, `skills`, `hooks`, `mcpServers`, `homepage`) have no field for a minimum host version. Fourteen installed manifests here declare none. A plugin author who builds against a CLI feature shipped in 2.1.271+ has no way to say so, and the older bundled runtime in the desktop app loads the plugin anyway, with no signal that a feature it depends on is missing there.

This was already requested — #17272, "Plugin Version Compatibility: Add 'engines' field to plugin.json" — and closed by the inactivity bot despite a commenter explicitly asking for it to stay open ("It is still actual as far as I know, so dont close it please"). Cited here because the underlying gap is unchanged and the desktop/CLI version-skew case makes it routine rather than an edge case: it is not "if a plugin author uses a new feature," it is "every plugin cache on a machine that also runs the desktop app is read by two runtime versions as a matter of course."

What a user would expect: a VS Code extension declares `engines.vscode`, the minimum VS Code version it targets, in its own `package.json` (https://code.visualstudio.com/api/references/extension-manifest).

Fix-owner: Anthropic — runtime code (the plugin manifest schema has no such field to add); the desktop host's own bundled-runtime cadence is why the gap bites here specifically.

Minimal ask: an optional minimum-runtime field in `plugin.json`, and a visible message — not a silent skip — when the loading runtime is below it.

────────────────────────────────────────────────────────────────
ITEM E — the CLI's own prompt history is asymmetric: desktop-originated prompts never reach it
────────────────────────────────────────────────────────────────

What's shared: ~/.claude/history.jsonl, which the CLI reads to power its own prompt-recall feature. Here it holds 1,812 entries. On a day that included active use of both the desktop app and terminal sessions, every new entry (3, in the window checked) came from the CLI; the desktop session running that same day added zero, matching the file's own write attribution — it is CLI-only by design, not by an accident of that one day's sample.

Impact: someone who works across both surfaces and reaches for the CLI's prompt history — expecting it to be "my prompt history," since it is the one shared account and the one shared project — finds it silently missing everything typed into the desktop app.

Fix-owner: Anthropic — desktop host (the missing write side; the CLI's own read side already works as documented).

Minimal ask: either the desktop app appends to the same file (it already writes the shared ~/.claude.json and ~/.claude/.credentials.json, so this would not be a new write surface for it), or the CLI's history feature and its help text say plainly that it only covers terminal-typed prompts.

────────────────────────────────────────────────────────────────
ITEM F — the desktop app's chosen permission default silently governs every headless `claude -p` script on the machine
────────────────────────────────────────────────────────────────

What's shared: `permissions.defaultMode` in ~/.claude/settings.json. The documentation is explicit that this file is shared: "The desktop app reads the same settings files as the CLI and applies the permission mode to new local sessions" (code.claude.com/docs/en/permission-modes, "Switch permission modes" → Desktop tab, as fetched 2026-09-16). The same documentation is equally explicit that `claude -p` and the Agent SDK have their own, different built-in starting mode — Manual (`default`) — distinct from the `auto` mode a Pro/Max/Team terminal session starts in by default, and its own precedence table checks `permissions.defaultMode` from a settings file *before* that built-in default is ever reached.

Impact: choosing auto mode in the desktop app's own mode selector — presented to the user as a property of the app — also switches every unattended `claude -p` script on the machine from its own documented Manual default to auto, because both surfaces read the one global settings file and the CLI's built-in default only applies when no settings file sets one. On this machine, ~/.claude/settings.json has `defaultMode: auto`, and a `claude -p` process (CLI 2.1.273) checked while preparing this report confirmed it directly: it ran in auto mode rather than the Manual mode its own documentation says a `-p` session starts in by default, with the settings file's value as the only reason (per the precedence order the documentation itself lays out).

Fix-owner: Anthropic — runtime code. Today's sharing is documented and intentional, not a bug in the current behavior — the gap is that there's no finer-grained setting for someone who wants the app in auto mode without every scripted `-p` invocation following it.

Minimal ask: a per-surface variant of the setting (for example `defaultMode.print`, alongside the existing key), or, short of that, a note at the point the desktop app's own mode selector is changed that the choice also applies to terminal and scripted sessions on the same account — the same way the documentation already calls out the project-level `.claude/settings.json`/`.claude/settings.local.json` carve-out for auto mode specifically.

### What Should Happen?

No single fix closes all six — the specific, minimal change for each is stated under that item above. The pattern behind all of them: state that's genuinely security- or trust-relevant (item A), state whose shape changes between versions (items B, D), and state whose scope a user reasonably assumes is "this app" (items C, F) currently live in the same undifferentiated files as ordinary telemetry, with no environment or version tag anywhere. `mcpOAuth` inside the credentials store already shows the alternative works today: it's keyed per server. Extending that same idea — key or tag the parts of the shared state that need it — would close most of the six.

### Error Messages/Logs

```shell
The one recurring error text across this report (item B): "Corrupted config
backed up to: " followed by a reset to defaults — the same recovery
text described in #29105 and #29155. Every other item here is a silent
behavior difference, not a logged error, which is part of why each was easy
to miss from either surface alone.
```

### Steps to Reproduce

This report covers six related observations rather than one crash, so there's no single repro. The common setup: install the Claude desktop app and the Claude Code CLI on the same Windows account, use both across multiple projects for a few weeks (long enough for the desktop app's bundled runtime to drift a few minor versions behind the self-updating CLI), then inspect the shared files directly:

- ~/.claude.json — a key diff between two snapshots a few minutes apart shows the whole-file rewrite rate (item B) and the unscoped consent flags (item A). - Run any command inside the desktop app's own Bash tool, e.g. `env | findstr CLAUDE_CODE_ENTRYPOINT` on Windows (`env | grep` on macOS/Linux) — it reports `claude-desktop`, confirming the inheritance in item C. - ~/.claude/plugins/cache/*/*/*/plugin.json — grep for `engines` across an installed plugin cache; none declare one (item D). - ~/.claude/history.jsonl — tail it after typing a prompt into the desktop app; the prompt is absent (item E). - ~/.claude/settings.json — set `permissions.defaultMode` from the desktop app's own mode selector, then run `claude -p "echo test" --output-format json` from a terminal and check which mode it reports (item F).

### Claude Model

Not sure / Multiple models

### Is this a regression?

No, this never worked

### Last Working Version

N/A — not a regression

### Claude Code Version

2.1.273 (Claude Code) CLI; desktop 1.52386.6 bundling runtime 2.1.270; Windows 11 build 26200

Note for this field: the form's instruction, "Run claude --version", has no equivalent in the desktop app — the bundled runtime is not on PATH (it lives under %APPDATA%\Claude\claude-code\\), so a desktop-app user reads the version from /status inside a session instead (documented: "Open the Settings interface on the Status tab, showing version, model, account, and connectivity"), and gets 2.1.270 where the CLI says 2.1.273 — the same one-root skew as item D, surfacing in the bug form itself.

### Platform

Anthropic API

### Operating System

Windows

### Terminal/Shell

Windows Terminal

### Additional Information

Measured on one Windows machine running the Claude desktop app and the Claude Code CLI side by side for months, including long-lived `--resume` sessions and many `claude -p` child processes, as part of an extended automation workflow — the scale (36 projects, 1,812 history entries, several hundred desktop-labelled transcripts) comes from that use pattern, not from anything unusual about the setup itself.

One item this report deliberately treats as an observation rather than an ask: the CLI and the desktop app register different URL-scheme handlers (`claude-cli://` via HKCU, `claude://` via the desktop app's own MSIX manifest) — a platform-level difference, not a shared-file one, and outside what a runtime-code or desktop-host change can fix on its own. Worth naming because Windows itself is moving in a direction that would let an agent process run with its own identity rather than borrowing the signed-in user's: the "agent accounts" and "agent workspace" Microsoft describes as an experimental Windows 11 feature — "a separate standard account on your device that agents use when acting on your behalf" plus "a contained environment where agents can work in parallel with a human user" — are explicitly stated to be "available for other developers in preview soon" (https://learn.microsoft.com/en-us/windows/security/book/operating-system-agentic-security). Not an ask for this issue; naming it because it's the direction the platform side of this class of problem is already headed.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start by separating the six reported behaviors and tracing reads and writes of ~/.claude.json, ~/.claude/projects//.jsonl, and CLAUDE_CODE_ENTRYPOINT across the CLI and desktop runtimes. Compare the consent, high-churn config, and transcript-retention paths; done requires scoped behavior for each boundary with regression coverage for the reported cross-session cases.

Written by the indexing model from the issue text.

Assessment

Domain
cli, desktop, security
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
25/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.