[BUG] Desktop: unrequested background `git fetch` to origin on diff/commit refresh — untraceable by design, and no setting disables it

Status Open
Reported on v2.1.221
Maintainer reply None cached
Activity 6 comments · opened Aug 7, 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?

The desktop app — not the CLI — runs git fetch against origin with no user request, no permission prompt, and no record in any session transcript. It is triggered by ordinary UI work (computing a diff, listing commits, the diff prefetcher), so it needs no worktree, no PR, and no git-related prompt from the user.

This is related to #21108, but that report is about the CLI; the code path below exists only in the desktop app, so a CLI fix will not cover it. It is also the network-side analogue of #79713 (harness mutating the user's repo at session spawn with nothing in the transcript).

Where it comes from

Grepping the literal string --no-write-fetch-head:

| Binary | Hits |
|---|---|
| %APPDATA%\Claude\claude-code\2.1.221\claude.exe (desktop-managed CLI) | 0 |
| VS Code extension anthropic.claude-code-2.1.207 native binary | 0 |
| C:\Program Files\WindowsApps\Claude_1.25927.0.0_x64__pzs8sxrjxfjjc\app\resources\app.asar | 1 |

It is emitted by the desktop Electron main process, from the worktree manager's refreshSourceRef():

execFetchCapped(
  ['fetch', '--no-write-fetch-head', 'origin', `+refs/heads/${n}:refs/remotes/origin/${n}`],
  baseRepo, kt, signal
)

and the -c prefix matches the observed command exactly:

var M  = ['-c','safe.directory=*'],
    N  = ['-c','core.fsmonitor=false'],
    Ce = ['-c','core.hooksPath=/dev/null'];
var we = [...Ce, ...M, ...N];

There are two trigger paths, and the second one is the problem:

  1. Worktree acquisition/creation — on a cold repo it first runs a full fetch --prune origin in the background (telemetry reason cold_full_fetch), then narrows to refreshSourceRef.
  2. GitStatusService — fetchGitDiffUncached, fetchGitDiffStatsUncached, getGitCommits, and the diff prefetcher each do this.config.refreshBaseRef && await this.config.refreshBaseRef(cwd, baseRef), which funnels into the same refreshSourceRef. No worktree required.

Why this is a security issue, not a UX annoyance

  1. Unrequested outbound connection using the user's credentials. On SSH remotes it consumes the SSH agent / key — exactly the symptom in #21108. On VPN-gated or air-gapped remotes it is an unrequested connection attempt to a host the user did not ask to contact at that moment.
  2. **The CLI itself classifies git fetch as not read-only.** The CLI bundle excludes git fetch / git pull from its read-only git set, on the stated grounds that despite looking read-only they amount to arbitrary code execution. The desktop app runs, unattended and unprompted, the very command the CLI refuses to auto-approve.
  3. --no-write-fetch-head means it leaves no trace. FETCH_HEAD is never updated, so the user cannot tell afterwards that it happened. I initially concluded my repo had never been auto-fetched because FETCH_HEAD was stale — that inference is wrong by construction.
  4. It silently advances refs/remotes/origin/<branch>, breaking bare git push --force-with-lease. The lease is taken against the remote-tracking ref, and a background fetch renews it behind the user's back. Same class of harm as #77660 ("refs/remotes/* must remain truthful"), but here it is a concrete data-loss vector.

There is currently no way to disable it

I grepped the desktop bundle for autoFetch, disableFetch, fetchDisabled, skipFetch, offlineMode, disableGit — zero hits. The only related key, useWorktree, is a per-session/scheduled-task parameter and does not affect path 2, so opting out of worktrees does not stop this.

What Should Happen?

  1. Add a setting that disables background remote git access, e.g. git.autoFetch: "never" | "worktree-only" | "always", honored by both trigger paths and readable from settings.json so it can also be applied as a managed/enterprise policy.
  2. Make it opt-in, or at minimum prompt once per repository. Contacting a remote with the user's credentials should not be a silent default — this is the same consent boundary the CLI already enforces by excluding git fetch from its read-only set.
  3. Until (1) ships, document the behavior, including that --no-write-fetch-head makes it untraceable and how it interacts with git push --force-with-lease.

Error Messages/Logs

Observed process (Windows 11, desktop app is the parent process):

git.exe -c core.hooksPath=/dev/null -c safe.directory=* -c core.fsmonitor=false fetch --no-write-fetch-head origin +refs/heads/master:refs/remotes/origin/master

No corresponding entry in any session transcript. .git/FETCH_HEAD is NOT updated by this command, so the repository retains no evidence that it ran.

String search for --no-write-fetch-head:
  %APPDATA%\Claude\claude-code\2.1.221\claude.exe                              0 hits
  .vscode\extensions\anthropic.claude-code-2.1.207-win32-x64\...\claude.exe    0 hits
  C:\Program Files\WindowsApps\Claude_1.25927.0.0_x64__pzs8sxrjxfjjc\app\resources\app.asar   1 hit

Guards observed in app.asar (constant names are minifier output for build 1.25927.0.0):
  Z  = 1800*1e3   30-minute TTL + in-flight dedup on the worktree path
  kt = 1e4        10s cap on the narrow fetch
  Ot = 3e4        30s cap on the full "fetch --prune origin"
  separate TTL on the diff path (telemetry reason: diff_base_ref_timeout)
  failures are non-fatal: logged as "continuing with on-disk refs"

Telemetry events already emitted for this code path:
  desktop_ccd_worktree_source_ref_fetch
  cold_full_fetch
  desktop_ccd_worktree_fetch_degraded

Steps to Reproduce

  1. Open a repository that has an origin remote in the desktop app's Code tab. No worktree, no PR, no git-related prompt to Claude.
  2. Let the app render the changes/diff view or the commit list. (The diff prefetcher will also do this on its own, without interaction.)
  3. Observe the spawned processes. On Windows:

``powershell
Get-CimInstance Win32_Process -Filter "Name = 'git.exe'" |
Select-Object ProcessId, ParentProcessId, CommandLine
``

The fetch --no-write-fetch-head origin +refs/heads/<branch>:refs/remotes/origin/<branch> command appears, parented to the desktop app.

  1. Check .git/FETCH_HEAD — it is not updated, so nothing in the repo records that the fetch happened.

With an SSH remote whose key carries a passphrase, this surfaces as an unexplained passphrase/agent prompt with no user action behind it — the same symptom as #21108, but a different code path.

Note on timing: a 30-minute TTL applies, so if you have used the repo recently you may need to wait it out before the fetch fires again.

Claude Model

Other

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

2.1.221 (Claude Code)

Platform

Anthropic API

Operating System

Windows

Terminal/Shell

Other

Additional Information

  • Surface: Claude Desktop Code tab, not a terminal — the "Terminal/Shell" dropdown has no matching option, hence "Other".
  • Desktop app version: 1.25927.0.0 (Windows 11 x64, MSIX install).
  • Bundled CLI: 2.1.221. VS Code extension present at 2.1.207 (also unaffected).
  • Remote: self-hosted GitLab over SSH (git@<internal-git-host>:...). Repo has no worktrees (git worktree list shows only the main working tree).
  • All findings above come from grepping app.asar of build 1.25927.0.0. The minified identifiers (Z, kt, Ot, we) will change between builds; the stable anchors are the method name refreshSourceRef, the literal flag --no-write-fetch-head, and the telemetry event names.
  • Related: #21108 (same symptom, CLI code path), #79713 (harness mutating the repo at session spawn with no transcript record), #77660 (truthfulness of refs/remotes/*).

View original on GitHub ↗

3 Comments

stu43005 · 2 months ago

Follow-up: still present on desktop 1.30096.1.0

Retested a week later, two desktop releases on from the original report (1.25927.0.0 → 1.26832.0.0 → 1.30096.1.0, bundled CLI now 2.1.229).

Unchanged, byte-for-byte: the fetch argv, execFetchCapped()'s extraEnv, the 10s timeout, and the refreshSourceRef / refreshBaseRef call sites. Only minified identifiers moved (kt → Y). Not repeating the code from the original post.

Still no opt-out. autoFetch, disableFetch, fetchDisabled, skipFetch, offlineMode, disableGit all return 0 hits, as do disableBackgroundGit* variants matching the naming proposed in #86363. The complete Code-tab Preferences set is unchanged: ccAutoArchiveOnPrClose, ccBranchPrefix, ccMaxWarmWorktrees, ccRecipients, ccRemoteControlDefaultEnabled, ccWorktreeReapAfterHours.

Worth noting that this module was not untouched during the week — other methods on the same worktree-manager class were edited in 1.30096.1.0. The unrequested remote access on the diff path was simply left as-is.

Independent confirmation on another platform: #86363 (Linux, labeled area:security) captured the same behavior at runtime via /proc + cgroup attribution, including the same GIT_SSH_COMMAND=ssh -o BatchMode=yes this code sets. Their passphrase-protected key pops a gcr-ssh-askpass dialog on every background access; mine is managed by 1Password and pops its authorization prompt. Neither of us issued any remote git command.

stu43005 · 1 month ago

Still reproduces on desktop 1.34493.1.0 (2026-08-21 build, CLI 2.1.237). The fetch argv, execFetchCapped()'s extraEnv, and the full GitStatusService → refreshBaseRef → refreshSourceRef chain are unchanged; all opt-out key candidates still return 0 hits.

Correction to my earlier comment: the diff path does not use the 30-minute default. This build de-minified the constant — it passes DIFF_BASE_REF_MAX_AGE_MS = 6e5, i.e. 10 minutes. So on the path that needs no worktree and no user action, an unrequested fetch can fire every 10 minutes for as long as the app is open on a repo.

stu43005 · 1 month ago

Still reproduces on desktop 1.40609.0.0 (2026-08-29 build, CLI 2.1.247) — five desktop releases since this was filed. The fetch argv, execFetchCapped()'s extraEnv, and DIFF_BASE_REF_MAX_AGE_MS (6e5, 10 minutes) are unchanged, and every opt-out key candidate still returns 0 hits.

This build did change the diff path — by investing in it, not by gating it:

  • refreshBaseRef on the diff-stats path moved off the awaited path into refreshBaseRefBehindStats(), which fires it in the background with an in-flight dedup set. Previously a memo miss meant the stats Promise resolved only after the network round-trip returned (up to the 10s cap); now the stats return immediately and the fetch happens behind them. Same fetch, same frequency — just no longer visible as a delay.
  • refreshSourceRef now returns a status (ok / joined / throttled / failed / skipped_ref) so callers can tell whether a fetch actually happened; refreshBaseRef collapses that to o === 'ok' || o === 'joined'.
  • A new onBaseRefMoved callback invalidates and recomputes the diff, then notifies every session reading that cwd, when the background fetch advances the base ref.

In other words, the unrequested fetch is now the trigger for automatic diff refresh — actively developed, non-blocking, deduplicated — and still with no way for the user to decline it.

Showing cached comments. Read the full discussion on GitHub ↗