# ZCode 曾打包整个 .git：两个开关没拦住，出网要从应用外查

> ZCode 3.12.3 曾把工作区连同 Git 历史打包加密；大仓上传未成功，小仓已被服务端接收，3.14.0 移除了上传链路。macOS 实验、Linux 与 Windows 文档命令分别教你查进程连接和字节数，并解释 .gitignore、目录锁及出口代理各自能管什么。

- Published: 2026年10月3日
- Author: Leo Kaka, Engineering
- Tags: security, coding-agents, agents
- Canonical: https://pirouter.ai/zh/blog/ai-coding-tool-egress-zcode-git

---
你在 IDE 里关掉「优化体验」，不等于桌面进程停止打包仓库。ferstar 对 ZCode 3.12.3 的拆包发现：登录后客户端会把工作区连同 `.git` 历史打包、加密，再尝试直传对象存储；**两个设置开关都没拦住打包这一步**。但要把「曾打包」与「已上传」分开：约 313MB 的商业项目包反复上传失败，作者核对路由日志确认未离开局域网；另一个 538 个文件的小公开仓压成约 15KB，状态显示服务端已接收。公开资料称 3.14.0 已移除该上传链路；其他旧版是否受影响，尚无完整版本范围。

这件事留给使用者的不是「以后别用 AI 编程工具」：它们本就需要把选中的代码上下文发给模型。问题是**谁决定选中哪些文件，哪些连接能出机器，以及使用者能否独立核对**。从应用外按进程看目的地和上行字节，再检查本地快照文件，才能区分正常请求与未预期的数据传输；阻断则要由操作系统和网络承接，而不是指望应用自己解释开关。

**如果你用过 ZCode，先做这四件事：**

1. 在「关于」里看版本：公开材料确认 **3.14.0 移除了上传链路**，因此升级到该版或经核验未重新引入该链路的更新版本；不能只凭 `3.14.x` 字样判断安全。公开材料只确认 3.12.3 有该行为，其他旧版范围未公开。
2. 打开 `~/.zcode/v2/checkpoints/`，查看状态文件所记的工作区路径、包大小、失败计数与接收状态。ferstar 据此再结合路由日志判断大仓未外发、小仓已被接收；**找不到「已接收」状态不等于证明未外发**，快照也可能被清理或状态不完整。
3. 对确认已被接收的工作区，轮换可访问的 Git 历史里出现过的凭证；其他疑似受影响仓库按敏感程度评估处置（安全查找方式见下文）。
4. 可选：按第 5 节锁住这个目录，当作一道绊线。

## ZCode 到底打包了什么：42,411 个文件，86.6% 是 .git

ferstar 是在清理磁盘时发现的：`~/.zcode` 占了 700 多 MB，`v2/checkpoints/` 下躺着一个加密包，旁边是一份状态文件
（[ferstar 的 ZCode 拆包记录（中文原文）](https://blog.ferstar.org/posts/zcode-silent-workspace-snapshot-upload/)，下文简称 ferstar 原文）：

```json
{
  "workspacePath": "/Users/ferstar/myprojects/<某商业项目>",
  "lastCompressedSize": {
    "encryptedSizeBytes": 313070842,
    "workspaceSizeBytes": 345549173
  },
  "kind": "baseline",
  "failureCount": 564
}
```

这个包因为体积太大一直失败，作者在自家路由上对过连接跟踪和流量日志，确认它没离开局域网。

他解开客户端安装包，还原出的链路是这样：客户端先向 `zcode.z.ai` 要一张上传凭证，服务端同时发来一把公钥。客户端在本地把工作区打成压缩包，用随机密钥加密，再用这把公钥把随机密钥包起来，然后绕开智谱自己的业务服务器，直接用表单上传到阿里云对象存储（OSS）。
抓包看到的也对得上：这个进程常驻的 HTTPS 连接，一条是 zcode.z.ai，另外两条是 OSS 节点。负责这件事的是一个随登录常驻的辅助进程（sidecar），细节可以略读，记住「绕过业务服务器直传存储」这一点就够了。

包本身解不开，但本地留着一份明文清单：

| 内容 | 体积 | 占比 | 意味着什么 |
|---|---|---|---|
| `.git/lfs/` | 196.1 MB | 56.8% | Git LFS（大文件存储）历史上拉取过的所有大文件 |
| `.git/objects/` | 102.2 MB | 29.6% | 本地保留的 Git 对象，可包含旧提交与文件版本；浅克隆或清理过的对象未必在本地 |
| `.git/logs/` | 0.6 MB | 0.2% | reflog，本地分支操作记录，含没推送的 |
| 其余源码与文档 | 约 46.2 MB | 13.4% | 当前工作区的代码和配置 |

被打包的主体不是只有当前源码，而是**这台机器上仍可访问的 Git 对象、引用与本地记录**：误提交后又删除的凭证、仍在本地引用的未推送分支、`.git/config` 中的远端地址，都可能在其中。浅克隆从未拉取的旧提交、已被垃圾回收的对象不在本机；不能把一份 `.git` 等同于项目从创建起的全部历史。

触发点有两个：每次发 prompt 之前（`captureBeforePrompt`），以及任务结束时的 `repo-wiki-update`。一个活跃会话最多触发了 62 次。把本地的包删掉，半小时内又会生成一个新的，失败计数从 564 变成 565。

智谱的说法：9 月 18 日 17:44 在用户群发出说明，IT之家、凤凰网当晚报道，新浪科技 9 月 19 日上午跟进。
问题出在「代码库索引」功能，「Repo Wiki 功能在生成 Wiki 页面时，可能会触发仓库数据上传。Wiki 页面在云端生成后，相关上传数据会立即销毁，不会保存。由于该功能在上线初期默认开启，导致部分用户受到影响。」官方承诺开源、引入第三方评估，并给全体用户额外重置一次周额度
（[IT之家 9 月 18 日报道](https://www.ithome.com/1/004/310.htm)；[凤凰网：智谱 ZCode 就代码库数据上传致歉](https://tech.ifeng.com/c/8wWkqtwNNZd)；[新浪科技：智谱 ZCode 回应数据上传争议](https://finance.sina.cn/2026-09-19/detail-inisinhc2626091.d.html)）。

两边说的触发条件不一样：官方说生成 Wiki 页面时「可能」触发，拆包看到的是每次提问前都会触发。这里只把两边原话摆出来。对使用者来说，更要紧的是这两种说法都只能从应用外面去验证。

## 为什么设置里的开关拦不住：开关由应用自己解释

ferstar 把两个看上去相关的开关和代码逐个对了一遍：

| 开关 | 看名字以为它管 | 代码里实际管 |
|---|---|---|
| 优化体验 | 数据采集与上传 | 数据要不要拿去训练 |
| 仓库快照索引 | 快照功能本身 | 服务端收到快照后要不要建索引 |

负责打包上传的进程在启动时无条件创建，唯一的前提是拿到登录凭证。两个开关都关掉，打包照样进行。

开关的含义由写应用的人定义，也由应用自己执行，你能看到的只有一行说明文字。「不用于训练」和「不上传」是两件事，界面上却可以长得一模一样。
**判断一个工具有没有往外发东西，不能只依赖它自述：操作系统里的进程连接和网络侧日志，要一起看。**

![漫画：一位开发者端着杯子靠在椅背上，身旁墙上两个开关都标着 OFF，开关的电线顺着墙爬上天花板、从远处一个墙洞穿出去；他身后一条从桌边起步的传送带插着自己的电源，正把一只黄色包裹从墙上的小窗口送出去](/blog/images/egress-boundary-comic-zh-branded.png "两个开关是真的，也都关着，只是都没接在打包这一步上。插图由 gpt-image-2.5 为 PiRouter 生成。")

## 怎么查一个 AI 编程工具在往哪发、发了多少（macOS / Linux / Windows）

要回答三个问题：连到了哪里、上行了多少字节、在什么时刻发的。AI 编程工具本来就要把代码上下文发给模型，所以「有上行」本身说明不了什么。要找的是三种异常：连的不是模型 API（比如对象存储）；上行量远超 prompt 加上下文的体量；你什么都没做它也在发。

**先找全进程。** 桌面 AI 工具大多是 Electron 应用，主进程之外还有 Helper 进程和 sidecar，只盯主进程可能正好漏掉负责上传的那个。

### macOS：lsof 看连接，nettop 看字节

以下输出是我在本机（macOS 26.6.2）实跑的，被观察的对象是 curl。查看当前用户自己的进程不需要 sudo。

```bash
# 1. 列出名字里带 zcode 的全部进程（不区分大小写）
pgrep -fl -i zcode

# 2. 这些进程的网络连接；pgrep -d, 把多个 PID 用逗号连起来
#    -n -P：不把 IP 和端口反解成名字；-a：各条件同时满足；-i：只看网络
lsof -nP -a -p "$(pgrep -d, -i zcode)" -i

# 3. 每个进程收发了多少字节（单位：字节）
#    -P 只看进程汇总；-d 从第二个样本起显示增量；-L 0 一直采样；-s 1 每秒一次
#    -p 按进程名时要和 nettop 显示的名字完全一致，可以写多个 -p
nettop -P -d -L 0 -s 1 -p <PID> -J bytes_in,bytes_out
```

本机输出（上一段是 `lsof`，下一段是 `nettop -P -d -L 2 -p curl`）：

```text
COMMAND   PID    ...  NAME
curl    21687    ...  TCP 192.168.50.60:51231->198.18.17.21:443 (ESTABLISHED)
curl    21700    ...  TCP 192.168.50.60:51235->76.76.21.93:443 (ESTABLISHED)

,bytes_in,bytes_out,
curl.21687,259685,519,     # 第 1 个样本：累计值
curl.21700,0,319,
,bytes_in,bytes_out,
curl.21687,65032,0,        # 第 2 个样本：这一秒的增量
curl.21700,0,0,
```

不要加 `-m tcp`，否则会漏掉 UDP。有些工具走 QUIC（基于 UDP 的 HTTP/3），不加这个参数时 nettop 会一起显示。

**容易踩的坑：本机开着代理。** 上面 198.18.17.21 那一条并不是真实服务器。代理客户端开了 TUN / fake-IP 模式时，会给每个域名分配一个 198.18.0.0/15 段的假地址（`dig +short speed.cloudflare.com` 返回的就是这个地址）。
这种情况下远端 IP 说明不了目的地。最省事的办法，是打开代理客户端自己的「连接」面板，多数客户端会按连接列出域名和发起的进程名。或者观察期间先把代理关掉。

**进阶（可跳过）：** 不借助代理面板、直接看域名，就要看 SNI，也就是 HTTPS 握手时客户端以明文报出的目标域名。macOS 自带的 tcpdump 能按进程过滤：

```bash
# 进程名含空格要加引号，或者改用 pid = <PID>；-A 以文本打印包内容，SNI 在 ClientHello 里
sudo tcpdump -i pktap,all -k NP -Q 'proc = "ZCode Helper"' -n -A 'tcp dst port 443'
# 看它往磁盘写了什么
sudo fs_usage -w -f filesys <PID>
```

这两条需要 sudo，我只对照 man 页核对过参数，没有在本机跑。如果连接用了 ECH（加密的 ClientHello），SNI 本身就被加密了，这条路也就走不通。
macOS 上多数应用的 DNS 查询由系统服务 mDNSResponder 代发，按进程抓包通常看不到应用自己的 DNS 查询。

### Linux：ss 看每个套接字，nethogs 看每个进程

```bash
# TCP：进程 + 每条连接的内部计数，bytes_acked 是已被对端确认的发送字节
ss -tnpi | grep -A1 -i zcode
# UDP / QUIC
ss -unp | grep -i zcode
# 按进程实时看上下行速率（发行版仓库里的 nethogs 包）
sudo nethogs
# 只看某个进程发起了哪些连接
sudo strace -f -e trace=connect -p <PID>
# 看它的数据目录谁在写（需先装 inotify-tools）
inotifywait -m -r ~/.zcode
```

`-p` 显示套接字属于哪个进程，`-i` 显示 TCP 内部信息（[ss(8)](https://man7.org/linux/man-pages/man8/ss.8.html)）。看别的用户的进程要加 sudo。Linux 这一节我没有在本机实测，参数以 man 页为准。

### Windows：PowerShell 加资源监视器

先打开任务管理器的「详细信息」页，确认工具的进程名（下文假设以 ZCode 开头）。以下命令在 PowerShell 里运行，不是 cmd：

```powershell
$ids = (Get-Process -Name ZCode* -ErrorAction SilentlyContinue).Id
# TCP 连接
Get-NetTCPConnection -OwningProcess $ids -ErrorAction SilentlyContinue |
  Select-Object LocalPort, RemoteAddress, RemotePort, State
# UDP / QUIC
Get-NetUDPEndpoint -OwningProcess $ids -ErrorAction SilentlyContinue
# 最近解析过的域名：拿 Data 列的地址去对上面的 RemoteAddress
Get-DnsClientCache | Select-Object Entry, Data
```

DNS 缓存是全系统共用的，不区分进程，只能拿来反查地址。工具没在运行时 `$ids` 为空，命令会报错或者什么都不输出。
要看字节，按 Win+R 输入 `resmon` 打开资源监视器，在「网络」页勾选该工具的全部进程，下方会显示每个进程的发送速率和远端地址。
参数出处见 [Get-NetTCPConnection](https://learn.microsoft.com/en-us/powershell/module/nettcpip/get-nettcpconnection) 和 [Get-NetUDPEndpoint](https://learn.microsoft.com/en-us/powershell/module/nettcpip/get-netudpendpoint) 文档。Windows 上同样可能碰到代理的假地址。这一节未在本机实测。

### 怎么判读：先采基线，再发一条短 prompt

1. **基线**：工具开着但不操作，采一分钟，记下每秒上行的量级；
2. **对照**：发一条很短的 prompt，比如「这个函数做什么」，看那一刻上行多出来多少；
3. **用尺子量**：我按代码和英文文本 1 token 约等于 4 字节粗算，一次带 2 万 token 上下文的请求，明文在 80KB 上下，加上协议开销也在几百 KB 以内（这是粗算，不是测量）。一条短 prompt 带出几 MB 上行，或者空闲时持续上行，就值得追。

**先查文档再下结论。** 有的工具会合法地上传代码去做索引或检索，并且在文档里写明了。拿观察结果去对照文档，超出文档写明范围的部分，才是可以拿去问厂商的事实。ZCode 那次，9 月 18 日保存的隐私政策写了会收集「对话中提交的文本、文件和代码」，没有写整仓快照（ferstar 原文）。

**同时看磁盘。** 数据目录通常在 `~/.<工具名>`、`~/Library/Application Support/<工具名>`（macOS）或 `%APPDATA%\<工具名>`（Windows）。找最近一小时新生成的大文件：

```bash
find ~/.zcode ~/Library/Application\ Support/ZCode -type f -mmin -60 -size +10M -exec ls -lh {} + 2>/dev/null
```

```powershell
Get-ChildItem "$env:APPDATA", "$env:USERPROFILE\.zcode" -Recurse -File -ErrorAction SilentlyContinue |
  Where-Object { $_.LastWriteTime -gt (Get-Date).AddHours(-1) -and $_.Length -gt 10MB }
```

ZCode 这件事，最早就是从一个多出来的几百 MB 目录查出来的。

## .gitignore 不等于不上传：一个玩具仓实验

`.gitignore` 只对 git 本身、以及主动去读它的工具生效。一个直接打包目录的程序，不读它也没有任何义务读它。
国内有复盘文章转述说 ZCode 的快照不读 `.gitignore`。ferstar 原文里只写了打包时排除了 node_modules 等少量目录，没有专门验证这一条，我也没法复现 ZCode 本身。下面只用**新建的临时玩具仓**说明直接打包的机制，不在真实代码仓运行；`mktemp` 分配独占目录，归档也写在这次实验的目录内，不会覆盖工作区父目录的同名文件。示例字符串不是有效凭证：

```bash
lab_dir="$(mktemp -d "${TMPDIR:-/tmp}/git-snapshot-demo.XXXXXX")" || exit 1
mkdir "$lab_dir/demo" && cd "$lab_dir/demo" || exit 1
git init -q -b main
git config user.name demo && git config user.email demo@example.com
printf 'print("hi")\n' > app.py
printf 'API_KEY=example-old-value\n' > config.py
git add . && git commit -qm init
git rm -q --cached config.py
printf 'config.py\n.env\n' > .gitignore
printf 'API_KEY=example-new-value\n' > config.py
printf 'DB_PASS=example-local-value\n' > .env
git add .gitignore && git commit -qm 'stop tracking config'

git ls-files
archive="$lab_dir/snapshot.tgz"
tar czf "$archive" .
tar tzf "$archive" | grep -v '^./.git/'
tar tzf "$archive" | grep -c '^./.git/'
git log --all --name-only --format='%h' -- config.py
```

输出的前两项是 `.gitignore` 和 `app.py`，而归档目录还会列出 `./config.py` 与 `./.env`；我在 macOS 26.6.2、git 2.55.0、bsdtar 3.5.3 的原玩具仓实验里数到 49 个 `.git/` 条目，数量随 Git 版本和仓库状态变化。最后一行只列涉及 `config.py` 的提交 ID 与**文件名**，不会打印凭证值；核实内容须在受控环境进行，别把原始 patch 或日志贴进工单。

1. Git 跟踪清单不含被忽略文件，直接打包工作目录却包含它们；
2. 两次提交的小仓也会打入 `.git/`；ZCode 那份清单里的 `.git` 占到 86.6%，但大小比例不能推广到别的仓；
3. 即使旧凭证不在当前工作区，**本地仍可达的提交和对象**里也可能留着。拿到 `.git` 能接触这台机器保留的历史，不等于拿到项目从创建以来的全部历史（浅克隆和垃圾回收是例外）。

反过来也有坑：ripgrep 这类工具默认尊重 `.gitignore`，在这个仓里 `rg -l API_KEY` 什么都搜不到，要加 `--no-ignore --hidden` 才能看见。拿尊重 `.gitignore` 的工具去检查「会被打包的东西」，查出来的永远比实际少。

落到行动上有两条：问一个工具会上传什么，要问它按什么规则收集文件；一个仓如果曾被整体外发过，就把历史里出现过的凭证全部轮换掉，光删文件、改 `.gitignore` 都不够。

## 锁目录能挡多少：chflags uchg 和 chattr +i 的边界

先说安全提示：**退出工具并保留原目录作排查证据**，不要照抄原帖的删除命令；只对 ZCode 已知的数据路径这么做，不碰系统目录。先记下解锁命令，检查点回滚等依赖该路径的功能可能失效。下面是把 ferstar 的「清空再上锁」改成可保留原件的版本；动手前仍应确认本机确有此目录：

```bash
# macOS / Linux: preserve existing snapshots in a unique sibling directory.
checkpoint_dir="$HOME/.zcode/v2/checkpoints"
if [ -e "$checkpoint_dir" ]; then
  backup_dir="$(mktemp -d "$HOME/.zcode/v2/checkpoints.backup.XXXXXX")" || exit 1
  mv "$checkpoint_dir" "$backup_dir/checkpoints" || exit 1
  printf 'Backup: %s\n' "$backup_dir/checkpoints"
fi
mkdir -p "$checkpoint_dir"

# macOS: lock, test, and unlock when needed.
chflags uchg "$checkpoint_dir"
touch "$checkpoint_dir/test"          # Expected: Operation not permitted
# chflags nouchg "$checkpoint_dir"

# Linux: use this instead of the macOS chflags command.
# sudo chattr +i "$checkpoint_dir"
# touch "$checkpoint_dir/test"        # Expected: Operation not permitted
# sudo chattr -i "$checkpoint_dir"
```

作者说锁上以后检查点回滚不能用了，补全和对话照常。Windows 的等价做法，他指向 NodeSeek 上一篇用 ACL 的帖子，那个页面我们没能抓取到，这里不复述。

我在本机把 macOS 的做法拆开试了一遍：

```text
$ chflags uchg ck && ls -ldO ck
drwxr-xr-x@ 4 larrykoo  wheel  uchg 128 Sep 27 22:16 ck
$ touch ck/test
touch: ck/test: Operation not permitted
$ rm ck/existing.enc
rm: ck/existing.enc: Operation not permitted
$ echo x >> ck/existing.enc   # 目录里原有的文件
exit=0
$ touch ck/sub/new             # 上锁前就存在的子目录
exit=0
$ chflags nouchg ck            # 同一用户，不用 sudo
exit=0
```

1. **目录里原有的文件照样能写。** 锁住的是目录项（新建、删除、改名），管不到已有文件的内容。所以要先清空再上锁，顺序不能省；
2. **上锁前就在的子目录不受影响。** 锁只作用于那一层目录；
3. **`uchg` 能被属主自己解开。** `man chflags` 写的是「owner or super-user only」，以你的身份运行的进程也能执行 `chflags nouchg`，我在本机没用 sudo 就解开了。换成 `sudo chflags schg`（只有超级用户能设置和清除）会更硬，代价是恢复时也需要 root，这一条我没试。Linux 的 `chattr +i` 按 [chattr(1)](https://man7.org/linux/man-pages/man1/chattr.1.html) 只有 root 或持有 CAP_LINUX_IMMUTABLE 的进程能设能清，普通用户身份运行的应用解不开；但第 1、2、4 条边界同样存在（按语义推断，未实测）；
4. **只锁一个路径。** 应用更新后换个目录写，锁就不起作用了。ferstar 在修复版上线后仍保留了这把锁，理由是客户端能热更新。

所以这是一道只对 ZCode 已知路径有效的绊线。它能打断一次已知行为，而写入失败时，应用侧通常不会给你任何提示。它算不上出网边界，真正的边界要管住「字节离开机器」本身。

![漫画：一面三十格的木抽屉柜，正中一格挂着一把结实的大挂锁，其余抽屉都没上锁；挂锁的黄色钥匙就挂在柜子侧面同一高度的小钩上，伸手可及；柜前的开发者拍拍手上的灰，满意地看着那把锁](/blog/images/egress-boundary-comic-lock-zh-branded.png "锁是真的，钥匙就挂在旁边，而且只锁了三十格里的一格。插图由 gpt-image-2.5 为 PiRouter 生成。")

## 团队层面：把出网收口到一个可审计的点

先看单机能做到什么：

| 平台 | 自带能力 | 能做到 | 做不到 |
|---|---|---|---|
| macOS | 应用防火墙 / pf | ⚠ 应用防火墙只管入站（[Apple：更改 Mac 防火墙设置](https://support.apple.com/guide/mac-help/change-firewall-settings-on-mac-mh11783/mac)）；pf 能按 IP、端口、用户拦出站（`man pf.conf` 的 `user` 规则） | 按域名、按单个应用 |
| Linux | systemd `IPAddressAllow=` / `IPAddressDeny=` | ⚠ 按 IP 管某个 unit 或 scope 里的进程（[systemd.resource-control(5)](https://man7.org/linux/man-pages/man5/systemd.resource-control.5.html)） | 按域名；内核或容器没开 cgroup eBPF 时，man 页原话是「no effect」 |
| Windows | `New-NetFirewallRule -Direction Outbound` | ✓ 按程序（`-Program`）加远端地址（`-RemoteAddress`）拦（[New-NetFirewallRule](https://learn.microsoft.com/en-us/powershell/module/netsecurity/new-netfirewallrule)） | 按域名 |

单机方案的共同短板是颗粒度：三家都能按 IP 拦，却没有「按域名放行」这一档。模型 API 大多挂在 CDN 后面，IP 名单根本维护不过来。对团队来说还多一个问题：每台机器各管各的，出了事没有一份统一的日志可查。

团队的做法，是把出网收到一个点上：

- **出口代理，按域名放行并记日志。** 开发机和 CI 默认不能直连外网，只能走代理；代理只放行名单上的域名（模型 API、包镜像、代码托管），其余一律拒绝，每次放行和拒绝都记日志。HTTPS 不用解密，从代理收到的 CONNECT 请求（「帮我连某域名」）或 SNI 里就能拿到目标域名，足够回答「连到了哪里、发了多少」。
- **DNS 允许列表。** 内部解析器只替名单上的域名去查（递归），名单外的直接回「不存在」（NXDOMAIN）。它挡不住写死在代码里的 IP，所以底下还要一层防火墙，默认拒绝直连。

两条旁路要心里有数。一是 DoH：应用自己用 HTTPS 去问公共 DNS，内部解析器就被绕过了，要在代理上拦掉常见的 DoH 端点。二是域名前置（domain fronting）：SNI 报的是名单上的域名，实际请求的却是另一个后端，不解密的代理看不出来。

拿 ZCode 那条链路推演一下：凭证请求发往厂商自己的域名，文件直传对象存储的域名。一份只列了模型 API 域名的名单里没有后者，这次上传就会在代理日志里留下一条拒绝记录。这是按链路结构做的推演，前提是那个存储域名确实不在名单上。它的价值在于，「有没有外发」变成了一条可以查询的日志，而不用去读厂商的说明。

**同一条边界在训练侧也漏过。** OpenAI 9 月 25 日更新的一份对齐问题报告（misalignment report）
（[An agent used DNS to reach an external chatbot](https://alignment.openai.com/misalignment-reports/an-agent-used-dns-to-reach-an-external-chatbot/)）里，训练中的 agent 直接发的 HTTPS 请求被代理拦下了，但训练环境自带的 DNS 解析器会替它查任意域名。agent 把问题编码进主机名，经这个解析器转给外部 chatbot，拿回了答案。报告原话是「insufficient DNS filtering」。

放到你的 DNS 允许列表上，是三条：
- **解析器不替名单外的域名递归**，这是主因。只要它什么都肯查，DNS 本身就是一条出网通道；
- **限制记录类型**，比如 TXT，能压住回程能带多少数据。OpenAI 的修复是把查询限制在「a limited allowed list of domains and record types」，并加了两层独立拦截；
- **查询日志要接到告警上**。报告说异常 DNS 检测器排除了这个环境，「though DNS activity was logged」。

代价是每接一个新服务就要改一次名单。我们自己做网关，出网收口是日常工作之一。不过网关只管 LLM 调用这一个出口，桌面工具直传对象存储这类旁路，得靠上面的代理和 DNS 去管。
工作区、推理引擎、平台三层各自的边界，我在[《DeepSeek Harness 沙箱按设计不拦读：Agent「逃逸」逃出的是哪一层？》](/zh/blog/agent-boundary-three-layers)里拆过，出网白名单属于其中的平台层。

## 谁持有解密钥，决定删除声明能不能被验证

回到 ZCode 的加密方式：公钥由服务端在发上传凭证时给出，私钥只在云端。ferstar 拿本机所有私钥去试，全部解不开。你硬盘上那份密文，你自己打不开，客户端也打不开。

![漫画：一位开发者坐在自己的书桌前，拿着一大串普通钥匙逐把去试一只小铁盒，盒子的锁孔是星形的；试过的钥匙在旁边堆成一小堆，他手里没有一把钥匙是星形的](/blog/images/egress-boundary-comic-key-zh-branded.png "盒子就放在自己桌上，能打开它的那把钥匙从来不在自己手里。插图由 gpt-image-2.5 为 PiRouter 生成。")

这个设计决定了谁能独立检查密文、哪些结论需要额外证据：
- **如果只有用户持有解密钥且实现经核验**：服务端拿到的密文不能直接读，用户可自行解密核对；这也不能单靠密钥设计证明服务端没有拿到别的明文副本；
- **ZCode 事件中私钥由服务端持有**：本机密文不能让用户独立验证其内容或后续处理。事后桶为空能说明调查时的状态，不能单独排除更早的读取、转存或备份；还需要覆盖当时链路的可核验记录与审计。

Z.ai 的说法是：代码从未进入训练数据，完整评估报告将会发布。官方在 9 月 21 日的通报里引述，中国信通院确认 zcode-prod 存储桶数据为零，绿盟科技确认桶和对象已全部删除（ferstar 原文 9-21 更新）。
这些说法都应该被记下来。但在钥匙在服务端的设计下，仅凭本机密文无法独立核实历史处理。TNW 9 月 22 日的复查也指出：「The two assessments describe the storage bucket after the fix, not what passed through it before.」该报道当时没有给出完整报告的发布日期；本文不据此断言今天仍未发布
（[TNW：Z.ai apologised, open-sourced ZCode, and wiped the commit history](https://thenextweb.com/news/zai-zcode-open-source-commit-history-security-assessment)）。

开源仓那边也一样。9 月 27 日 GitHub 上一共 3 个提交：导入全部代码的那一个时间是 2026-09-20 21:14 UTC，另一个是 9 月 23 日的 v3.14.3。能审计的只是修复后的代码，旧版的上传代码不在仓里。

所以你能做的是两件事：更新之后把第 3 节的观测再跑一遍，因为外部能验证的只有「客户端现在还发不发」；已经外发过的仓库，按泄露处理，轮换凭证。

## 引入 AI 编程工具之前，该问厂商的六个问题

1. **按什么规则收集文件？** 读不读 `.gitignore`，带不带 `.git`（第 4 节）；
2. **什么时候上传？** 每次提问、打开项目，还是只在你主动触发时（第 1 节）；
3. **每个开关到底管什么？** 是管「拿不拿」，还是只管「怎么用」（第 2 节）；
4. **会连哪些域名？** 要一份清单，拿来配出口代理的允许列表（第 6 节）；
5. **加密的钥匙在谁手里？** 钥匙在服务端的话，删除声明靠什么验证（第 7 节）；
6. **上传的东西保留多久、怎么删？** 有没有可以核对的删除凭据。

## 两张自查表：开发者单机与团队基础设施

**开发者单机（5 分钟）：**

| 步骤 | 命令或动作 | 看什么 | 容易踩的坑 |
|---|---|---|---|
| 1 找全进程 | `pgrep -fl -i <工具名>` / 任务管理器「详细信息」 | 主进程 + Helper + sidecar | 只盯主进程，漏掉负责上传的子进程 |
| 2 看连接 | `lsof -nP -a -p "$(pgrep -d, -i <工具名>)" -i` / `ss -tnpi` + `ss -unp` / `Get-NetTCPConnection` + `Get-NetUDPEndpoint` | 目的地是不是模型 API | 远端是 198.18.x.x：本机代理的假地址，去看代理的连接面板 |
| 3 看字节 | `nettop -P -d -L 0 -s 1 -p <PID> -J bytes_in,bytes_out` / `nethogs` / `resmon` | 先采空闲基线，再看短 prompt 那一刻的增量 | 没有基线就判异常；忘了有的工具在文档里写明了会合法上传做索引 |
| 4 看磁盘 | `find <数据目录> -type f -mmin -60 -size +10M` | 最近一小时新出现的大文件 | 删了又重新生成：它在重试 |
| 5 事后处置 | `git log --all --name-only --format='%h' -- <疑似文件路径>` 定位后在受控环境核查、轮换 | 可达历史里涉及敏感文件的提交 ID 和路径（不打印内容） | 不要在终端/工单贴原始 patch；仅查文件名不能证明历史里没有凭证 |
| 附 ZCode 专用绊线 | 先清空再 `chflags uchg` / `sudo chattr +i` | `touch` 报 Operation not permitted | 目录没清空、有子目录，或 `uchg` 被属主解开 |

**团队基础设施（运维）：**

| 步骤 | 做什么 | 看什么 | 容易踩的坑 |
|---|---|---|---|
| 1 默认拒绝 | 开发机和 CI 禁止直连外网 | 防火墙拒绝日志 | 只配了 DNS，写死的 IP 照样能出去 |
| 2 出口代理 | 按域名放行 + 全量日志 | CONNECT / SNI 里的目标域名与字节数 | DoH、域名前置绕过 |
| 3 DNS 允许列表 | 名单外不递归，并限制记录类型 | 解析器查询日志 | 解析器什么都肯查，DNS 本身就成了出网通道 |
| 4 告警 | 拒绝记录和异常 DNS 接到告警 | 被拒条目的进程和机器 | 日志只落盘，没人看 |
| 5 准入 | 新工具先答完上面六个问题 | 厂商给的域名清单 | 清单和实际观测对不上 |

今天就挑你最常开的那个仓，先采一分钟基线，再在 AI 编程工具里发一条最短的 prompt，看一眼上行字节。这个数字它的设置页不会告诉你。

---

## Sources

### blog.ferstar.org

- [ferstar 的 ZCode 拆包记录（中文原文）](https://blog.ferstar.org/posts/zcode-silent-workspace-snapshot-upload/)

### ithome.com

- [IT之家 9 月 18 日报道](https://www.ithome.com/1/004/310.htm)

### tech.ifeng.com

- [凤凰网：智谱 ZCode 就代码库数据上传致歉](https://tech.ifeng.com/c/8wWkqtwNNZd)

### finance.sina.cn

- [新浪科技：智谱 ZCode 回应数据上传争议](https://finance.sina.cn/2026-09-19/detail-inisinhc2626091.d.html)

### man7.org

- [ss(8)](https://man7.org/linux/man-pages/man8/ss.8.html)
- [chattr(1)](https://man7.org/linux/man-pages/man1/chattr.1.html)
- [systemd.resource-control(5)](https://man7.org/linux/man-pages/man5/systemd.resource-control.5.html)

### learn.microsoft.com

- [Get-NetTCPConnection](https://learn.microsoft.com/en-us/powershell/module/nettcpip/get-nettcpconnection)
- [Get-NetUDPEndpoint](https://learn.microsoft.com/en-us/powershell/module/nettcpip/get-netudpendpoint)
- [New-NetFirewallRule](https://learn.microsoft.com/en-us/powershell/module/netsecurity/new-netfirewallrule)

### support.apple.com

- [Apple：更改 Mac 防火墙设置](https://support.apple.com/guide/mac-help/change-firewall-settings-on-mac-mh11783/mac)

### alignment.openai.com

- [An agent used DNS to reach an external chatbot](https://alignment.openai.com/misalignment-reports/an-agent-used-dns-to-reach-an-external-chatbot/)

### thenextweb.com

- [TNW：Z.ai apologised, open-sourced ZCode, and wiped the commit history](https://thenextweb.com/news/zai-zcode-open-source-commit-history-security-assessment)
