RC RANDOM CHAOS

Your Git history is already in the cloud

Zhipu's ZCode desktop app silently packaged entire Git histories, encrypted them with a server-held key, and uploaded them to Aliyun OSS.

· 5 min read
Your Git history is already in the cloud

A base-spec MacBook Air running low on disk space is how this came out. The ~/.zcode directory, the data root for ZCode (Zhipu’s official AI coding desktop app), had grown past 700MB. Most of it lived under ~/.zcode/v2/checkpoints, where a 313MB .enc file sat next to a state file describing it: a baseline snapshot of a commercial project, 345MB of working tree compressed and encrypted down to 313MB, carrying a failureCount of 564.

That snapshot was the whole repository. The client had scanned the active workspace, excluded node_modules and a few other directories, packed the rest into a tar.gz, encrypted it, and queued it for upload. The repo was about 10GB total; minus dependencies, the 345MB it packaged was almost entirely source.

How the upload works

Unpacking the client’s app.asar reconstructed the pipeline, which runs in two stages. First the client POSTs to /api/v1/snapshot/upload-credential on zcode.z.ai (the VITE_ZCODE_ENDPOINT_ORIGIN in the code). The server replies with a snapshot ID, OSS form credentials (policy, x-oss-signature, a dynamic object key), a size limit, a callback, and an RSA public key for this encryption round. Then the client archives the workspace locally, encrypts it with AES-256-CTR under an ephemeral symmetric key, wraps that key with RSA-OAEP-SHA256 using the server’s public key, and POSTs the ciphertext straight to Aliyun OSS as an HTTP form upload. OSS calls back to Zhipu’s backend to register the snapshot. Inspecting live sockets matched this: persistent HTTPS to zcode.z.ai plus two Aliyun OSS nodes.

The wrapping key is the part worth sitting with. It is envelope encryption done by the book, except the RSA public key is handed down by the server at credential time and the matching private key never reaches your machine. The multi-hundred-megabyte ciphertext on your own disk cannot be decrypted by you or by the client; only Zhipu’s backend holds the key. Git and Time Machine keep their keys local because they are built for you to restore. A key that only the server can use means the server can read your code whenever it wants.

Almost all of it is .git

The ciphertext is locked, but the manifest generated during packaging is written to disk in plaintext. One snapshot inventoried 42,411 files, and the .git directory alone was 86.6% of the payload. Once uploaded, the cloud holds the full lineage of the repo since its first commit, well beyond your current working tree: API keys and configs that were deleted in later commits but still live in history, unpushed local branch names that telegraph unreleased work, and the internal GitLab hostnames and repository paths sitting in .git/config. A separate repo_snapshot_extra_manifest also hashes your global ZCode config (files like settings.behavior.json) and ships it with every snapshot, across workspaces.

The toggle does not exist

The obvious move is to find the setting and turn it off. Cross-referencing the UI against the code, the capture-and-upload sidecar is instantiated unconditionally at startup. There are no if checks against user preferences; the one precondition is that the token provider can return a valid JWT. While you are logged in, the pipeline runs. Captures fire at captureBeforePrompt (before every prompt you send) and on task completion tagged repo-wiki-update; one active session logged 62 capture events.

What actually left the machine

The 313MB commercial snapshot never made it out. It exceeded the server’s size limit, failed 564 times, and stayed in the local pending directory; connection-tracking records on the author’s home OpenWrt router confirmed those chunks never crossed the network. A smaller workspace did go. A public-repo project of 538 files, about 15KB after compression and encryption, was accepted by the server. Other people reproduced the same directory layout and accepted-looking snapshots on Windows. So at least one repository left at least one machine with no notice.

ZCode’s privacy policy, archived on 18 September and still showing a last-updated date of 15 June 2026, says it collects “text, files, and code submitted during conversations,” which is ordinary for feeding context to a model. Nowhere in the policy, FAQ, or changelog does it mention packaging and uploading whole workspaces and full Git histories.

The company response

Z.ai posted a statement on 18 September. It attributed the behavior to “codebase indexing” used for local indexes, session checkpoint restore, and a Repo Wiki feature; said generating Wiki pages in the cloud “may” trigger an upload of repository data; said that data is “destroyed immediately” after the Wiki is generated and not stored; and said the feature was on by default early on, some users were affected, and the issue “has been fixed.” Zhipu no longer disputes that uploads happened.

On 21 September the company published what it called its open-source core (GitHub zai-org/ZCode, Apache-2.0) as two commits: an empty initial commit and a single dump of 6,973 files and 1.03 million lines, with PRs locked and Issues disabled. The development history was flattened, so there is no diff showing the upload sidecar being removed. Searching that tree for /api/v1/snapshot/upload-credential, the direct OSS PostObject, the AES-256-CTR packaging, and the public-key delivery returns nothing. The checkpoint code that was supposedly the reason for the uploads (gitCheckpointService.ts, gitCheckpointRepo.ts) runs entirely on the local Git CLI, git diff --name-status and git diff --numstat scoped to the current workspace, storing JSON metadata under ~/.zcode/checkpoints/, with no cloud dependency. The stated justification for whole-repo uploads does not survive its own source.

Third-party reviews were cited. CAICT reported the zcode-prod bucket holds zero data and that client v3.14.0 removed the snapshot and upload workflow, and NSFOCUS reported the bucket and its objects deleted with no outbound paths detected. Emptying a bucket after 20 September says nothing about what was read from data uploaded before 18 September, and no post-incident audit can reconstruct that backward.

Shut it off locally

Deleting the pending archive is whack-a-mole; within half an hour the uploader repacked a fresh 313MB file and the retry counter ticked from 564 to 565. The durable fix is a kernel-level immutability flag on the checkpoints directory, so the capture code’s writes fail at the filesystem:

# macOS
rm -rf ~/.zcode/v2/checkpoints
mkdir -p ~/.zcode/v2/checkpoints
chflags uchg ~/.zcode/v2/checkpoints

# Linux
rm -rf ~/.zcode/v2/checkpoints
mkdir -p ~/.zcode/v2/checkpoints
sudo chattr +i ~/.zcode/v2/checkpoints

A touch inside the directory should now return “Operation not permitted.” Normal chat, autocomplete, and tool execution keep working; the checkpoint-rollback UI stops. Reverse it with chflags nouchg or sudo chattr -i when you want the feature back.

Version 3.14.0 already removed the code and the gateway route now returns 404, and a runtime check of 3.14.3 found no repoSnapshot logic in the unpacked app.asar and no new .enc archives being written. The client still takes hot updates, and a matching version tag on a published source tree is not a byte-for-byte guarantee about a proprietary binary. The directory lock stays useful as a tripwire for that reason.

Share

Keep Reading

Latest on the Wire

Full wire →

New signal daily · RSS

Stay in the loop

New writing delivered when it's ready. No schedule, no spam.