我不是英语母语者;这篇文章由 AI 翻译。
事情始于一次清理磁盘空间时的例行检查:~/.zcode 占用了超过 700MB。在断断续续地深入排查后,我确认了一件相当离谱的事:
只要你处于登录状态,ZCode(智谱官方的 AI 编程桌面应用)就会悄悄地把你的整个工作区——完整的 .git 历史记录、LFS 资源缓存、reflog 以及全局应用配置——打包、加密,然后直接上传到阿里云 OSS。
更讽刺的是:用于加密的 RSA 公钥是由服务器动态下发的,而私钥只存在于云端。你无法解密就躺在你自己磁盘上的那几百兆密文,ZCode 客户端本身也做不到。
以下是完整的调查记录、证据链,以及一条能永久关闭它的单行防御措施。
起点:一个卡在待处理状态的 313MB 归档文件 #
~/.zcode 是 ZCode 的数据根目录。体积构成大致如下:
cli/:约 257MB(会话数据库、执行日志)computer-use/:约 130MB(打包的应用及运行时依赖)v2/checkpoints/:约 303MB(主要嫌疑对象)
在 v2/checkpoints/ 内部,我发现了一个 313MB 的 .enc 文件,旁边还有一个状态元数据文件:
{
"workspacePath": "/Users/ferstar/myprojects/<a commercial project>",
"lastCompressedSize": {
"encryptedSizeBytes": 313070842,
"workspaceSizeBytes": 345549173
},
"kind": "baseline",
"failureCount": 564
} 事情的经过很直白:
- 客户端扫描了我正在进行的商业项目,排除了
node_modules和其他几个文件,然后把剩余的 345MB 打包成一个 313MB 的加密归档文件,标记为baseline(完整快照); - 它记录了 564 次上传失败尝试,最终让它停留在本地
pending/目录中,等待下一次重试。
该仓库总计 10GB;减去依赖项后,剩余的 345MB 几乎全是核心知识产权。
它去了哪里:从日志到 asar 逆向工程 #
日志中没有明确的上传 URL,所以我拆开了客户端的 app.asar。重建后的上传流程如下:
sequenceDiagram
participant C as ZCode client
participant S as zcode.z.ai
participant O as Aliyun OSS
C->>S: POST /api/v1/snapshot/upload-credential
S-->>C: snapshot_id + RSA public key + max_size + OSS form credentials + callback
C->>C: tar.gz pack → AES-256-CTR encrypt → RSA-OAEP wrap key
C->>O: PostObject direct upload of tar.gz.enc
O->>S: callback confirms receipt
该流水线分两个阶段运行:
- 向协调器请求凭证:客户端调用
https://zcode.z.ai(代码中为VITE_ZCODE_ENDPOINT_ORIGIN)。服务器返回 OSS 表单签名(policy、x-oss-signature)、一个动态 Object Key、大小限制,以及本轮加密所用的 RSA 公钥; - 直接以表单 POST 上传至 OSS:在本地完成归档和流式加密后,客户端绕过 ZCode 自己的应用服务器,通过 HTTP POST 表单
tar.gz.enc直接上传至阿里云 OSS。随后 OSS 回调智谱后端以登记该快照。
检查活动套接字证实了这一点:运行中的 ZCode 进程维持着到 zcode.z.ai IP 端点以及两个阿里云 OSS 存储节点的持久 HTTPS 连接。
最讽刺的部分:密钥属于服务器#
该加密实现采用的是教科书式的信封加密:
keyId: String(i.encryption.key_version),
keyWrapAlgorithm: "rsa-oaep-sha256",
publicKeySpkiPem: Ylt(i.encryption.public_key) - 内容通过 AES-256-CTR 使用临时对称密钥进行加密;
- 该对称密钥使用服务器提供的公钥,通过 RSA-OAEP-SHA256 进行封装。
关键问题在于那把公钥:它是在凭证协商过程中由服务器下发的,而对应的私钥永远不会触及你的机器。用我系统上所有本地私钥来解封信封密钥都失败了,这在意料之中。
换句话说:你硬盘上那 313MB 的密文,你或客户端都无法打开。只有智谱的后端持有解锁它的密钥。
如果这个功能真的是为用户侧回滚或跨设备同步而构建的,密钥本应保存在本地(就像 Git 或 Time Machine 那样)。一个只有服务器才能使用的密钥只有一个用途:确保服务器随时都能读取你的代码。
打包了什么:近 90% 是 .git #
尽管密文是加密锁定的,但打包过程中生成的 Manifest(文件清单)以明文形式保存在本地。拆解一份包含 42,411 个文件的快照:
| 内容 | 大小 | 占比 | 包含的信息 |
|---|---|---|---|
.git/lfs/ | 196.1 MB | 56.8% | LFS 缓存——所有曾下载过的二进制资源和大体积媒体文件 |
.git/objects/ | 102.2 MB | 29.6% | 完整的提交历史对象存储(提交、树、blob) |
.git/logs/ | 0.6 MB | 0.2% | reflog——本地分支历史与未推送的操作痕迹 |
| 源代码与文档 | 约 46.2 MB | 13.4% | src/、配置文件、内部文档 |
仅 .git 目录就占了整个数据载荷的 86.6%。
一旦上传,云端收到的远不止你当前的工作树——它得到的是你仓库自第一天起的完整谱系:
- 在后续提交中已被删除的历史 API 密钥和敏感配置;
- 未推送的本地分支名称(会泄露尚未发布的功能计划);
- 在
.git/config中配置的内部 GitLab 主机名和仓库路径。
此外,还有一个名为 repo_snapshot_extra_manifest 的额外清单文件会对你的全局 ZCode 配置文件(例如 settings.behavior.json)进行哈希处理,并在每次快照时将它们跨工作区打包。
关于开关的真相:UI 开关并不能阻止它 #
自然的反应是去检查设置把它关掉。我将 UI 选项与代码库进行了交叉比对:
| 开关 | 你以为它会做什么 | 它实际做了什么 |
|---|---|---|
优化体验(optimizeAgentExperienceEnabled) | 禁用遥测 / 数据收集 | 仅控制数据是否被授权用于模型训练。快照捕获和上传仍在运行 |
仓库快照索引(repoSnapshotIndexingEnabled) | 禁用快照功能 | 仅控制服务器是否对上传的快照建立索引。本地打包和上传仍不间断进行 |
查看宿主程序集代码就一目了然:捕获/上传 sidecar 在启动时被无条件实例化。没有任何基于用户偏好的门控if检查;唯一的要求是tokenProvider能够返回一个有效的 JWT。
归根结底:只要你处于登录状态,这条后台流水线就永久处于活跃状态,没有任何 UI 设置能将其关闭。
捕获触发发生在两个时间点:captureBeforePrompt(每次提示词之前)以及任务完成并标记为repo-wiki-update时。在会话日志中,单个活跃会话最多产生了 62 次捕获事件。
隐私政策怎么说 #
查阅 ZCode 的隐私政策,其中明确声明会收集“对话过程中提交的文本、文件和代码”——这是为 LLM 提供上下文的常规做法。
然而,纵观整份政策、FAQ 和更新日志,没有任何一处提及会静默打包并上传整个工作区和完整的 Git 历史记录。
最接近的表述是那条通用模板声明:“优化程序默认关闭,未经同意输入内容不会被用于训练”。
防御:删除是打地鼠;锁定目录 #
当我第一次发现那个待处理的软件包时,我直接把它删掉了。不到半小时,它又重新捕获了——一个全新的 313MB 归档文件,重试计数器从 564 跳到了 565。当上传方发现文件不见了,它就会重新打包一个新的。手动删除就是打地鼠。
最干净、最有效的解决方案是在文件系统层面设置不可变标志,在内核层面拒绝写入权限:
macOS #
# Wipe and lock the checkpoints directory
rm -rf ~/.zcode/v2/checkpoints
mkdir -p ~/.zcode/v2/checkpoints
chflags uchg ~/.zcode/v2/checkpoints
# Verify: should output "Operation not permitted"
touch ~/.zcode/v2/checkpoints/test Linux #
# Wipe and lock the checkpoints directory
rm -rf ~/.zcode/v2/checkpoints
mkdir -p ~/.zcode/v2/checkpoints
sudo chattr +i ~/.zcode/v2/checkpoints
# Verify: should output "Operation not permitted"
touch ~/.zcode/v2/checkpoints/test 影响与回滚 #
- 结果:每当捕获逻辑尝试磁盘 I/O 时,都会被内核阻止。没有本地产物,上传流水线就没有东西可发送;
- 权衡:“检查点回滚 / 时间线”UI 功能将无法使用(该功能本来就一直要求上传你的代码)。正常聊天、自动补全和工具执行不受影响。日志中被吞掉的 I/O 错误是无害的;
- 恢复方法:运行
chflags nouchg ~/.zcode/v2/checkpoints(macOS)或sudo chattr -i ~/.zcode/v2/checkpoints(Linux)。
结语 #
使用 AI 工具时,模型推理不可避免地需要代码上下文——这一点大家都接受。但这一行为显然在两个方面越了界:
第一,数据范围。推理发送的是与任务相关的上下文;而快照外泄的却是整个代码仓库,连同多年的 Git 提交历史。
第二,架构姿态。如果这真的是为用户侧恢复或同步而设计,解密密钥本应归用户所有。而加密密钥完全由服务器持有、隐私政策中零披露、后台上传无法阻止、删除后仍顽固地重新打包——这看起来远不像备份,更像是数据收集。
工具就是工具,但用户必须自己划定边界。如果软件不让你关掉它,那就用操作系统内核把它锁进笼子里。
相关
I am not a native English speaker; this article was translated by AI.
It started with a routine check while freeing up disk space: ~/.zcode was taking up over 700MB. After digging into it intermittently, I confirmed something pretty wild:
Whenever you are logged in, ZCode (Zhipu’s official AI coding desktop app) silently packages your entire workspace — complete .git history, LFS asset cache, reflogs, and global app configs — encrypts it, and uploads it directly to Aliyun OSS.
Even more ironic: the RSA public key used for encryption is delivered on the fly by the server, while the private key lives exclusively in the cloud. You cannot decrypt that multi-hundred-megabyte ciphertext sitting right on your own disk, and neither can the ZCode client itself.
Here is the complete record of the investigation, the evidence chain, and a one-liner defense that permanently shuts it down.
The Starting Point: A 313MB Archive Stuck in Pending #
~/.zcode is the data root of ZCode. The size breakdown looked roughly like this:
cli/: ~257MB (session databases, execution logs)computer-use/: ~130MB (bundled app and runtime dependencies)v2/checkpoints/: ~303MB (the main suspect)
Inside v2/checkpoints/, I found a 313MB .enc file alongside a state metadata file:
{
"workspacePath": "/Users/ferstar/myprojects/<a commercial project>",
"lastCompressedSize": {
"encryptedSizeBytes": 313070842,
"workspaceSizeBytes": 345549173
},
"kind": "baseline",
"failureCount": 564
} The story was straightforward:
- The client scanned my active commercial project, excluded
node_modulesand a few others, and packaged the remaining 345MB into a 313MB encrypted archive labeledbaseline(full snapshot); - It recorded 564 failed upload attempts, leaving it sitting in the local
pending/directory waiting for the next retry.
The repository totaled 10GB; minus dependencies, the remaining 345MB was almost entirely core intellectual property.
Where It Goes: From Logs to asar Reverse Engineering #
The logs contained no explicit upload URLs, so I cracked open the client’s app.asar. The reconstructed upload flow:
sequenceDiagram
participant C as ZCode client
participant S as zcode.z.ai
participant O as Aliyun OSS
C->>S: POST /api/v1/snapshot/upload-credential
S-->>C: snapshot_id + RSA public key + max_size + OSS form credentials + callback
C->>C: tar.gz pack → AES-256-CTR encrypt → RSA-OAEP wrap key
C->>O: PostObject direct upload of tar.gz.enc
O->>S: callback confirms receipt
The pipeline runs in two stages:
- Request credentials from coordinator: The client calls
https://zcode.z.ai(VITE_ZCODE_ENDPOINT_ORIGINin code). The server returns OSS form signatures (policy,x-oss-signature), a dynamic Object Key, size limits, and the RSA public key for this encryption round; - Direct form POST to OSS: After archiving and streaming encryption locally, the client bypasses ZCode’s own application servers and posts
tar.gz.encdirectly to Aliyun OSS via an HTTP POST form. OSS then calls back to Zhipu’s backend to register the snapshot.
Inspecting active sockets confirmed this: the running ZCode process maintained persistent HTTPS connections to zcode.z.ai IP endpoints plus two Aliyun OSS storage nodes.
The Most Ironic Part: The Key Belongs to the Server #
The encryption implementation uses textbook envelope encryption:
keyId: String(i.encryption.key_version),
keyWrapAlgorithm: "rsa-oaep-sha256",
publicKeySpkiPem: Ylt(i.encryption.public_key) - Content is encrypted using an ephemeral symmetric key via AES-256-CTR;
- The symmetric key is wrapped using RSA-OAEP-SHA256 with the public key supplied by the server.
The critical catch is that public key: it is handed down by the server during credential negotiation, and the corresponding private key never touches your machine. Unwrapping the envelope key with all local private keys on my system failed, as expected.
In other words: that 313MB ciphertext on your drive cannot be opened by you or the client. Only Zhipu’s backend holds the key to unlock it.
If this feature were genuinely built for user-facing rollback or cross-device sync, the keys would live locally (just like Git or Time Machine). A key that only the server can use serves exactly one purpose: making sure the server can read your code whenever it wants.
What Gets Packed: Nearly 90% Is .git #
Even though the ciphertext is locked, the Manifest (file inventory) generated during packaging is saved locally in plaintext. Breaking down a snapshot of 42,411 files:
| Content | Size | Proportion | Information Contained |
|---|---|---|---|
.git/lfs/ | 196.1 MB | 56.8% | LFS cache — all binary assets and large media ever downloaded |
.git/objects/ | 102.2 MB | 29.6% | Complete commit history object store (commits, trees, blobs) |
.git/logs/ | 0.6 MB | 0.2% | reflogs — local branch history and unpushed operational traces |
| Source code & docs | ~46.2 MB | 13.4% | src/, config files, internal documentation |
The .git directory alone accounts for 86.6% of the payload.
Once uploaded, the cloud receives far more than your current working tree — it gets the entire lineage of your repository since day one:
- Historical API keys and sensitive configs that were deleted in later commits;
- Unpushed local branch names (which reveal unreleased feature plans);
- Internal GitLab hostnames and repository paths configured in
.git/config.
Furthermore, an extra manifest named repo_snapshot_extra_manifest hashes your global ZCode configuration files (such as settings.behavior.json) and bundles them across workspaces with every snapshot.
The Truth About the Switches: UI Toggles Don’t Stop It #
The natural reaction is checking settings to toggle it off. I cross-referenced the UI options with the codebase:
| Switch | What you expect it to do | What it actually does |
|---|---|---|
Optimize Experience (optimizeAgentExperienceEnabled) | Disables telemetry / data collection | Only controls whether data is authorized for model training. Snapshot capture and upload still run |
Repo Snapshot Indexing (repoSnapshotIndexingEnabled) | Disables the snapshot feature | Only controls whether the server indexes uploaded snapshots. Local packaging and upload continue uninterrupted |
Looking at host assembly code makes it crystal clear: the capture/upload sidecar is instantiated unconditionally at startup. There are no gating if checks on user preferences; the only requirement is that tokenProvider can return a valid JWT.
Bottom line: as long as you are logged in, this background pipeline is permanently active, and no UI setting can turn it off.
Capture triggers occur at two points: captureBeforePrompt (before every prompt) and on task completion tagged with repo-wiki-update. In session logs, a single active session generated up to 62 capture events.
What the Privacy Policy Says #
Checking ZCode’s privacy policy, it explicitly states that it collects “text, files, and code submitted during conversations” — standard practice for feeding context to LLMs.
However, across the entire policy, FAQs, and changelogs, there is not a single mention of silently packaging and uploading entire workspaces and full Git histories.
The closest mention is the generic template statement: “optimization program is off by default, and inputs will not be used for training without consent”.
Defense: Deleting Is Whack-a-Mole; Lock the Directory #
When I first found the pending package, I simply deleted it. Within half an hour, it re-captured — a fresh 313MB archive with the retry counter ticking from 564 to 565. When the uploader sees the file is gone, it just packs a new one. Manual deletion is whack-a-mole.
The cleanest and most effective solution is setting an immutability flag at the filesystem level, denying write access at the kernel level:
macOS #
# Wipe and lock the checkpoints directory
rm -rf ~/.zcode/v2/checkpoints
mkdir -p ~/.zcode/v2/checkpoints
chflags uchg ~/.zcode/v2/checkpoints
# Verify: should output "Operation not permitted"
touch ~/.zcode/v2/checkpoints/test Linux #
# Wipe and lock the checkpoints directory
rm -rf ~/.zcode/v2/checkpoints
mkdir -p ~/.zcode/v2/checkpoints
sudo chattr +i ~/.zcode/v2/checkpoints
# Verify: should output "Operation not permitted"
touch ~/.zcode/v2/checkpoints/test Impact & Rollback #
- Result: The capture logic gets blocked by the kernel whenever it attempts disk I/O. Without local artifacts, the upload pipeline has nothing to send;
- Trade-off: The “checkpoint rollback / timeline” UI feature won’t work (which always required uploading your code in the first place). Normal chat, autocomplete, and tool executions work without issue. Swallowed I/O errors in logs are harmless;
- To restore: Run
chflags nouchg ~/.zcode/v2/checkpoints(macOS) orsudo chattr -i ~/.zcode/v2/checkpoints(Linux).
Closing Thoughts #
When using AI tools, model inference inevitably needs code context — everyone accepts that going in. But this behavior clearly crosses the line in two ways:
First, data scope. Inference sends task-relevant context; snapshotting exfiltrates the entire repository along with years of Git commit history.
Second, architectural posture. If this were genuinely designed for user-side restore or syncing, the decryption keys would belong to the user. An encryption key held exclusively by the server, zero disclosure in privacy policies, unstoppable background uploads, and stubborn re-packaging upon deletion — this looks less like backup and far more like collection.
Tools are tools, but users must draw their own boundaries. If the software won’t let you turn it off, use the OS kernel to lock it in a cage.