xAI 的 Grok Build CLI 实际向 xAI 发送了什么:一次网络层面分析
一次有节制、可复现的拆解分析。研究结果由捕获的工件(端点、HTTP 方法、状态码、字节大小、主机)和复现命令支撑;对于现场观察到但未保存为文件的发现,第 7 节明确说明了这一点。第 8 节是证据附录,包含 SHA-256 哈希值和一份“我们未证明的内容”清单。所有捕获数据均来自我本机上的个人流量,使用一个包含虚假“金丝雀”密钥的一次性仓库——未暴露任何真实凭证。
0. 摘要
xAI 的官方 Grok Build 编码 CLI(grok),在普通消费者登录状态下,会执行三件值得精确记录的事情:
- 它会将其读取的文件内容——包括 .env 密钥文件——逐字且未经编辑地传输给 xAI。该密钥出现在两个通道中:实时模型交互轮次(POST /v1/responses)以及通过 POST /v1/storage 上传并被接受(HTTP 200)的 session_state 存档——该二进制文件将流量路由至 grok-code-session-traces GCS 存储桶(参见第 5 节)。
- 它会上传整个仓库——每个被追踪文件的内容以及 git 历史记录——这与智能体读取的内容无关。Grok 打包工作区并通过 POST /v1/storage 上传。直接证明:在一个真实代码库上,使用提示词“回复 OK,不要读取任何文件”,Grok 将整个仓库作为 git bundle 上传(POST /v1/storage → 200);通过 git clone 捕获的 bundle 可以恢复一个智能体被告知不要打开的文件——src/_probe/never_read_canary.txt——其中包含其唯一的标记原文,以及完整的 git 历史记录(附录 uploaded_repo.bundle)。并且其规模可扩展:在一个包含从未读取的随机文件、大小为 12 GB 的仓库上,/v1/storage 传输了 5.10 GiB 的数据,全部返回 HTTP 200(中途截断),而模型交互轮次通道仅传输了 192 KB——比例约为 27,800 倍,这明确将上传行为锁定在代码库本身,而非所读取的内容。没有存储上传失败;唯一的非 200 状态码是模型使用配额限制(402/429)出现在 /v1/responses 上,以及一个无关的 404——并非存储大小上限。
- 存储目标是一个 Google Cloud Storage 存储桶,grok-code-session-traces(而非 AWS S3)——该名称在二进制文件和捕获到的 metadata.json 中逐字出现(gs://grok-code-session-traces/…)。我没有在 CLI 的安装/快速入门材料中发现这一机制被公开提及(并非详尽的文档审计——见 §7),它默认处于激活状态,并且关闭“改进模型”并不会将其关闭(/v1/settings 仍然返回 trace_upload_enabled: true;见 §6)。
以上所有内容均不能证明 xAI 会利用这些数据进行训练——这是一个政策问题,将在 §6 中讨论。但已得到证实的是数据的传输、接收和存储行为。
1. 被测对象(来源追溯)
Install: curl -fsSL https://x.ai/cli/install.sh | bash # → ~/.grok/bin/grok
Auth: first launch opens a browser → login to X / SuperGrok (consumer account, not an API key)
二进制文件身份标识(可复现操作:file $(readlink -f ~/.grok/bin/grok);~/.grok/bin/grok --version;shasum -a 256 $(readlink -f ~/.grok/bin/grok)):
~/.grok/bin/grok -> ../downloads/grok-macos-aarch64
Mach-O 64-bit executable arm64
grok 0.2.93 (f00f96316d4b)
SHA-256: 2a97ba675bd992aa9b981e2e83776460d94f469b510c0b8efe28b50d236d767c
上传机制是一个第一方 Rust 库。对二进制文件执行 strings 命令可得到以下源码路径和常量(可复现操作:strings <binary> | grep -E 'xai-data-collector|grok-code-session-traces|storage.googleapis'):
crates/codegen/xai-data-collector/src/gcs.rs
crates/codegen/xai-data-collector/src/storage_client.rs
crates/codegen/xai-data-collector/src/queue.rs
crates/codegen/xai-data-collector/src/file_access_tracker.rs
crates/codegen/xai-data-collector/src/circuit_breaker_observer.rs
crates/codegen/xai-grok-shell/src/upload/{gcs,turn,trace,manifest}.rs
grok-code-session-traces
storage.googleapis.com
"Uploading bytes to GCS via proxy"
2. 方法(可复现)
环境:macOS,Apple Silicon,grok 0.2.93,2026 年 7 月。
- brew install mitmproxy;运行一次以在 ~/.mitmproxy/ 下生成其 CA 证书。
- Trust the CA in the keychain (no sudo; Grok does not certificate-pin against it):
security add-trusted-cert -r trustRoot -k ~/Library/Keychains/login.keychain-db \ ~/.mitmproxy/mitmproxy-ca-cert.pem - Run Grok routed through the proxy (a
mitmdumpaddon logs, per request: method, host, path, response status, request byte size; and saves request bodies for xAI hosts):HTTPS_PROXY=http://127.0.0.1:8080 SSL_CERT_FILE=~/.mitmproxy/mitmproxy-ca-cert.pem \ grok -p "<prompt>" --cwd <repo> - 对于分阶段工件检查,在运行期间竞态复制 ~/.grok/upload_queue/*,然后执行 gzip -dc | tar -xO。
金丝雀仓库:每个文件都带有一个唯一标记,因此捕获流量中出现的任何内容都可以明确追溯到某个文件。机密文件 secrets.env / .env:
API_KEY=CANARY7F3A9-SECRET-should-not-leave
DB_PASSWORD=CANARY7F3A9-DBPASS
3. 发现 1——文件内容(包括机密文件)被传输并被接收(200)
声明:当 Grok 读取一个文件时,其内容会被传输至 xAI——序列化到 POST /v1/responses 模型轮次请求体中,并打包成一个 session_state 归档文件,该文件被上传并被接收(HTTP 200),通过 POST /v1/storage 完成——且文件内容未经任何脱敏处理。一个 .env 文件会像其他任何文件一样被发送。
网络流量工件——一个已解密的 48,070 字节 POST cli-chat-proxy.grok.com/v1/responses 请求体(可通过其内嵌的 "messages":[…]"model":"grok-4.5" JSON 识别为模型轮次)。其中逐字包含了机密文件(附录:secrets_responses_body.bin,secret_verbatim.txt):
…API_KEY=CANARY7F3A9-SECRET-should-not-leave\nDB_PASSWORD=CANARY7F3A9-DBPASS\n…"model":"grok-4.5"…
复现步骤:`grep -a "CANARY7F3A9-DBPASS" secrets_responses_body.bin` → 匹配成功。所有六个文件标记(source、logic、README、嵌套 JS、API 密钥、数据库密码)均可从解密后的 `/v1/responses` 响应体中恢复。(此证据证明该秘密已被传输至 `/v1/responses` 端点;原始响应体文件不携带响应状态,因此接受(200)的断言锚定于紧接下方的 `/v1/storage` 通道,该通道的状态映射记录在 `wire_12gb.log` 中。)
第二通道——持久化至 Google Cloud Storage。相同内容被打包进一个 session_state 归档文件,通过 `POST /v1/storage` 上传。通过在其被清空之前解压暂存工件即可证明(附录:secrets_session_state.tar.gz):
gzip -dc secrets_session_state.tar.gz | tar -xO | grep -ao 'CANARY7F3A9-[A-Z]*'
→ CANARY7F3A9-SECRET, CANARY7F3A9-DBPASS, + all others
因此,该秘密不仅会在传输过程中被处理,还会被写入一个最终存入存储的归档文件中。
预先回应“你让它读取了机密信息”。我进行了一组对照实验:在代理被明确告知不得打开的文件(untouched_secret.txt)存在的情况下,使用提示词“请只回复OK,不要读取任何文件”。结果,在捕获到的任何响应体中,均未出现该文件的标记。因此,数据泄露的范围仅限于 Grok 实际读取的文件——但它的读取行为非常宽泛(任何与任务相关的文件都会读取,包括 .env 文件),并且对文件内容未做任何脱敏处理。这里的缺陷在于,一个机密文件在未脱敏的情况下被传输了出去,而非“读取”这个行为本身。重要的范围界定说明:这个对照实验表明,未读取的文件不会出现在 /v1/responses 的响应体中——这是通道 A(代理读取的文件)。但这并不能排除第 4 节中提到的整个仓库的 /v1/storage 快照(通道 B)——根据那里的数据量证据,该快照确实会扫描并包含从未被读取过的文件;我无法解压 /v1/storage 的代码库数据块来验证这个特定文件。所以,“未读取的文件不会被上传”这一结论仅适用于模型交互通道,不适用于代码库快照。(还有两点范围说明:(i) 在我的测试中,.env/secrets.env 文件是被 git 追踪的;我没有单独测试一个被 .gitignore 忽略的文件是否仍会被上传,因此我不对 gitignore 的情况做任何断言——根据 file_access_tracker 库的机制,上传是由读取行为驱动的,但这个特定场景未经测试。(ii) 金丝雀值位于 .env/secrets.env 文件中的 API_KEY=/DB_PASSWORD= 键值对里,但它们并非真实格式的高熵令牌;我证明了这个 .env 文件是在未脱敏的情况下被传输的,但这并不代表系统不存在针对,例如,sk-…- 格式密钥的脱敏器。)
4. 发现 2——整个仓库被以数 GB 的规模上传;唯一的限制是模型配额,而非存储空间大小
声明:在测试范围内,Grok 会上传整个仓库的快照,且不存在存储空间大小的限制。随着仓库体积增长,它会切换上传策略,并持续返回 200 状态码;对于一个 12 GB 的仓库,在捕获过程被中途截断之前,已经成功上传了 73 个约 75.0 MB 的数据块(总计 5.10 GiB),且零失败。
通过抓包工具捕获的、不同仓库大小的上传数据量扫描(内容不可压缩,因此 tar 包无法缩小;每次测试均使用全新的会话)。只有 12 GB 那一行的数据被保存为文件(wire_12gb.log);更小规模的数据行是在扫描过程中实时观察到的,其日志未被保存(参见第 7 节):
| 仓库大小 | 上传行为(在线观测) | 状态 | 产物 |
|---|---|---|---|
| 64 MB | 单次 POST /v1/storage,请求体=50548145b(48 MB) | 200 | 已观测,未保留 |
| ~600 MB | 以约 7.5 MB 为分片多次 POST /v1/storage(数十次) | 全部 200 | 已观测,未保留 |
| ~3 GB | POST /v1/storage/multipart/init → PUT storage.googleapis.com/grok-code-session-traces/multipart/<id>,以 50 MB 为分片(直接 GCS PUT 行未保留 — §7) | 全部 200 | 已观测,未保留 |
| ~12 GB | 以 75 MB 为分片 POST /v1/storage(请求体≈75014840b);在我停止运行前已捕获 73 个分片(约 5.1 GB) | 全部 200,0 次失败 | wire_12gb.log |
保留产物:wire_12gb.log(附录)。其中包含 83 条 /v1/storage* 的 200 响应:82 次内容上传 POST …/v1/storage 请求——其中 73 个分片各约 75.0 MB(字节大小最小 75,014,811 / 最大 75,014,871,总计 5,476,083,317 B = 5.10 GiB / 5.48 GB),外加 9 次较小的 POST——以及 1 次 /v1/storage/batch_exists 去重检查。/v1/storage* 请求总字节数:5,476,228,005 B。零次存储请求失败。捕获在上传仍在进行时被停止(最后一行是另一个约 75 MB 的分片 → 200),因此这证明了已上传 ≥5.1 GiB,截断时仍在增长——并非完整的 12 GB 已完成。复现方法(三次 grep,因此计数无歧义):grep 'cli-chat-proxy.grok.com/v1/storage' wire_12gb.log | grep -c '> 200' → 83(所有 /v1/storage*);grep 'POST cli-chat-proxy.grok.com/v1/storage ' wire_12gb.log | grep -c '> 200' → 82(仅内容 POST);grep req=75014 wire_12gb.log | grep -c '> 200' → 73(约 75 MB 的分片)。
范围——这是整个仓库,而不仅仅是智能体读取的文件。通道 A(§3,/v1/responses)承载智能体打开的文件。本 §4 的上传是一个独立的通道 B:整个工作空间的快照。两条证据:
- (a) The decisive byte split (load-bearing). In the same captured 12 GB session — a repo of 100 % random files the agent never read — the two channels moved wildly different volumes:
- 通道 A /v1/responses(模型轮次):196,705 B = 192 KB 总计,跨 5 次请求,单次最大轮次 60,394 B。
- 通道 B /v1/storage:5,476,228,005 B = 5.10 GiB。
- 这是一个约 27,800 倍的比率(5,476,083,317 ÷ 196,705)。该模型显然从未读取过这些文件(192 KB 无法承载 5 GiB 的内容),然而其中 5.10 GiB 的内容却通过 /v1/storage 流出——在整个扫描过程中,/v1/storage 的流量与仓库总大小保持一致(64 MB → 12 GB)。一个从未被读取的仓库流出 GB 级别的数据,只可能是整个仓库的快照。
- (b) 该二进制文件自身的路径/字符串佐证了这一机制:after_codebase.tar.gz、xai-grok-shell/src/upload/{trace,turn}.rs、repo_state.upload、"collecting workspace files"、"spawning background coordinator"。
- (c) 一份分阶段的代码库清单列举了一个从未被读取的文件,并将其内容寻址到 GCS 存储桶。在另一次针对真实包含 298 个文件的 cereblab_api 代码库的独立运行中,我抢先复制了 Grok 为上传而准备的快照(附录:staged_base_tree_manifest.json、staged_metadata.json)。该清单列出了 src/_probe/never_read_canary.txt——这是我植入且从未要求 Grok 打开的文件——以及真实的源代码文件(src/apns.ts、src/index.ts……);而 metadata.json 为每个文件分配了一个目标 fileId,格式为 gs://grok-code-session-traces/repo_changes_dedup/v2/supplemental/sha256_…。因此,整个目录树(包括未读取的文件)都被枚举并进行了内容寻址,以便上传至指定的 GCS 存储桶。(那次运行在内容数据块上传前因配额限制而中断;上述 12 GB 的运行则独立证明了 GB 级别的数据确实通过网络流出,全部 200 个文件均如此。)
- (d) 决定性证据——一个从未被读取的文件,其内容被逐字从上传的 git bundle 中恢复(单次 SuperGrok 运行)。在账户升级(配额解除)后,我使用明确的提示词“请仅回复:OK。不要读取或打开任何文件。”对真实的 cereblab_api 仓库重新运行,并在 src/_probe/never_read_canary.txt 中植入了一个唯一的标记 CANARY-XR47P2-NEVERREAD-UNIQUE。Grok 通过 POST cli-chat-proxy.grok.com/v1/storage 将整个仓库作为 git bundle 上传,返回 200 状态码(请求 ID=152102b)(附录:uploaded_repo.bundle,SHA-256 73b9c0af06311bae35c3ed03274d0eec2846e76762828d10b09757ca41bd6024)。运行 `git clone uploaded_repo.bundle` 可重建该仓库,且 src/_probe/never_read_canary.txt 中包含逐字的标记——这是一个智能体被明确告知不要打开的文件。该 bundle 还携带了完整的 git 历史(4 次提交,47 个文件)。这是无可辩驳的逐文件内容证明:整个仓库——包括未读取的文件——离开了机器并被接受(200 状态码)。上传机制是 git bundle,因此“整个仓库”是字面意义上的(每个被追踪的文件加历史记录)。在第二个不相关的代码库上进行了复现:对 cereblab_auth Cloudflare-Worker 仓库进行了完全相同的捕获,产生了 git-bundle 上传(POST /v1/storage → 200,31,743 字节),从中通过 `git clone` 恢复出了其自身的从未读取标记 CANARY-AUTH-4T8K2-NEVERREAD(附录:uploaded_repo_auth.bundle,SHA-256 0ee536538bcd1ee72a258f9977ab69f8a9b1ac240491b91a4e94335b4d83c768)。两个独立的仓库,结果相同。
(提示词说明:12 GB 的会话是交互式的,我没有记录其逐字提示词,但 192 KB 的 Channel-A 总量足以证明无论提示词是什么,都没有发生批量读取;另一次独立的无头控制运行使用了明确的提示词“请仅回复 OK,不要读取任何文件”,并确认未读取的文件在 Channel A 中不存在。)
(之前的“一个缺口”现在已被证据 (d) 填补:一次 SuperGrok 运行中,一个特定的从未读取文件的内容,从一次被抓包捕获的、返回 200 状态码的 git-bundle 上传中恢复。12 GB 的运行仍然是证明此行为可扩展到 GB 级数据量的证据。)
没有存储/上传请求失败——82 个 `/v1/storage` 调用全部返回 200。整个抓包中唯一的非 200 响应出现在模型端点上,外加一次会话记账调用(完整集合来自 wire_12gb.log;`/v1/responses` 行也包含在 model_limit.txt 中):
POST /v1/responses -> 402 (Payment Required) ×1
POST /v1/responses -> 429 (Too Many Requests) ×3
POST /v1/sessions/<id>/replicas/update -> 404 ×1 (session bookkeeping, not an upload)
最后,以纯文本形式输出到标准输出(stdout):
You've reached your free Grok Build usage limit for now. Get SuperGrok for much higher limits…
402/429 是模型使用配额;那唯一的 404 与存储无关。值得注意的是,在模型轮次被限速后,存储上传仍然继续返回 200(76 个 `/v1/storage` 的 200 响应出现在第一次 429 之时或之后)——代码库上传与模型是否回答无关。
先发制人地回应“你把本地磁盘缓存误认为是上传了”这种说法。这一论断严格基于通过抓包捕获到的、文件字节离开机器时的 200 状态上传(在保存的 wire_12gb.log 中,`/v1/storage` 请求体大小为 7.5–75 MB;向 storage.googleapis.com 发起的 3 GB 的 50 MB PUT 请求也在抓包中看到,但该日志未被保留——见第 7 节)。它不依赖于 `~/.grok/upload_queue` 的队列清空——队列清空含义模糊(它在成功和丢弃时都会清空),此处明确不作为证据使用。(早期版本根据队列清空推断上传的做法是错误的,已被撤回;见第 7 节。)
5. 发现 3——目标位置、遥测数据以及文档中未提及的内容
- 存储目标位置是 Google Cloud Storage,存储桶名为 grok-code-session-traces。这一结论基于保存的二进制字符串 grok-code-session-traces、storage.googleapis.com 以及“通过代理向 GCS 上传字节”(crate_strings.txt),还基于一份保存的分阶段 metadata.json,其中每个文件的 fileId 字面值为 `gs://grok-code-session-traces/repo_changes_dedup/v2/…/sha256_…`(staged_metadata.json),并且与在 3 GB 流量中观察到的直接向 storage.googleapis.com 发起的 multipart PUT 请求相互印证(实时观察;该日志未被保留——见第 7 节)。它不是 AWS S3(二进制文件中包含 aws-sdk-s3 作为备选路径,以及 AWS STS/SSO 用于认证,但二进制文件中命名的目标位置——以及在 3 GB 流量中实际看到的——是 GCS)。
- 第三方遥测:POST 到 api.mixpanel.com/track 和 /engage(Mixpanel),以及 POST 到 grok.com/_data/v1/events——全部返回 200。
- 在安装文档中并未明确提及(此为范围受限的声明):我在审查的 CLI 安装脚本或快速入门材料中,没有找到将 repo_state / session_state 上传至 grok-code-session-traces,或暂存至 ~/.grok/upload_queue 的相关描述(这并非对 xAI 所有文档的详尽审计——参见第 7 节)。该机制在标准消费者登录状态下默认处于激活状态。
- 可靠性说明(与隐私问题无关):~/.grok/upload_queue 每次交互会暂存约 3 GB 的快照,在高负载下可能增长至数十 GB 并耗尽磁盘空间。这是一个真实存在的缺陷,与上传是否成功无关。
6. 同意与政策——如实陈述
What xAI's Grok Build CLI Actually Sends to xAI: A Wire-Level Analysis
A measured, reproducible teardown. Findings are backed by captured artifacts (endpoint, HTTP method, status code, byte size, host) and repro commands; where an observation was seen live but not retained as a file, §7 says so explicitly. Section 8 is an evidence appendix with SHA-256s and a "what we did not prove" list. All captures are of my own traffic on my own machine, using a throwaway repository containing fake "canary" secrets — no real credentials were exposed.
0. Summary
xAI's official Grok Build coding CLI (grok), on a normal consumer login, does three things worth documenting precisely:
- It transmits the contents of files it reads — including a
.envsecrets file — to xAI, verbatim and unredacted. The secret appears in two channels: the live model turn (POST /v1/responses) and asession_statearchive uploaded and accepted (HTTP 200) viaPOST /v1/storage— the endpoint the binary routes to thegrok-code-session-tracesGCS bucket (see §5). - It uploads the whole repository — every tracked file's content plus git history — independent of what the agent reads. Grok packages the workspace and uploads it via
POST /v1/storage. Proven directly: on a real codebase, with the prompt "reply OK, do not read any files", Grok uploaded the entire repo as a git bundle (POST /v1/storage → 200);git clone-ing the captured bundle recovers a file the agent was told not to open —src/_probe/never_read_canary.txt— with its unique marker verbatim, plus the full git history (appendixuploaded_repo.bundle). And it scales: on a 12 GB repo of never-read random files,/v1/storagemoved 5.10 GiB, all HTTP 200 (truncated mid-stream), while the model-turn channel moved just 192 KB — a ~27,800× ratio that pins the upload to the codebase, not to what was read. No storage upload failed; the only non-200s were a model-usage quota (402/429) on/v1/responsesand one unrelated 404 — not a storage size cap. - The storage destination is a Google Cloud Storage bucket,
grok-code-session-traces(not AWS S3) — named verbatim in the binary and in a capturedmetadata.json(gs://grok-code-session-traces/…). I did not find this mechanism surfaced in the CLI's install/quickstart materials (not an exhaustive docs audit — §7), it is active by default, and disabling "Improve the model" does not turn it off (/v1/settingsstill returnedtrace_upload_enabled: true; §6).
None of this proves xAI trains on the data — that is a policy question addressed in §6. What is proven is transmission, acceptance, and storage.
1. Subject under test (provenance)
Install: curl -fsSL https://x.ai/cli/install.sh | bash # → ~/.grok/bin/grok
Auth: first launch opens a browser → login to X / SuperGrok (consumer account, not an API key)
Binary identity (repro: file $(readlink -f ~/.grok/bin/grok); ~/.grok/bin/grok --version; shasum -a 256 $(readlink -f ~/.grok/bin/grok)):
~/.grok/bin/grok -> ../downloads/grok-macos-aarch64
Mach-O 64-bit executable arm64
grok 0.2.93 (f00f96316d4b)
SHA-256: 2a97ba675bd992aa9b981e2e83776460d94f469b510c0b8efe28b50d236d767c
The upload machinery is a first-party Rust crate. strings on the binary yields these source paths and constants (repro: strings <binary> | grep -E 'xai-data-collector|grok-code-session-traces|storage.googleapis'):
crates/codegen/xai-data-collector/src/gcs.rs
crates/codegen/xai-data-collector/src/storage_client.rs
crates/codegen/xai-data-collector/src/queue.rs
crates/codegen/xai-data-collector/src/file_access_tracker.rs
crates/codegen/xai-data-collector/src/circuit_breaker_observer.rs
crates/codegen/xai-grok-shell/src/upload/{gcs,turn,trace,manifest}.rs
grok-code-session-traces
storage.googleapis.com
"Uploading bytes to GCS via proxy"
2. Method (reproducible)
Environment: macOS, Apple Silicon, grok 0.2.93, July 2026.
brew install mitmproxy; run once to generate its CA at~/.mitmproxy/.- Trust the CA in the keychain (no sudo; Grok does not certificate-pin against it):
security add-trusted-cert -r trustRoot -k ~/Library/Keychains/login.keychain-db \ ~/.mitmproxy/mitmproxy-ca-cert.pem - Run Grok routed through the proxy (a
mitmdumpaddon logs, per request: method, host, path, response status, request byte size; and saves request bodies for xAI hosts):HTTPS_PROXY=http://127.0.0.1:8080 SSL_CERT_FILE=~/.mitmproxy/mitmproxy-ca-cert.pem \ grok -p "<prompt>" --cwd <repo> - For staged-artifact inspection, race-copy
~/.grok/upload_queue/*during the run, thengzip -dc | tar -xO.
Canary repo: each file carries a unique marker so anything appearing in captured traffic is unambiguously traceable to a file. Secrets file secrets.env / .env:
API_KEY=CANARY7F3A9-SECRET-should-not-leave
DB_PASSWORD=CANARY7F3A9-DBPASS
3. Finding 1 — File contents, including a secrets file, are transmitted and accepted (200)
Claim: when Grok reads a file, its contents are transmitted to xAI — serialized into the POST /v1/responses model-turn body, and packaged into a session_state archive that is uploaded and accepted (HTTP 200) via POST /v1/storage — with no redaction of the file's contents. A .env is sent like any other file.
Wire artifact — a decrypted 48,070-byte POST cli-chat-proxy.grok.com/v1/responses request body (identifiable as a model turn by its embedded "messages":[…]"model":"grok-4.5" JSON). It contains the secrets file verbatim (appendix: secrets_responses_body.bin, secret_verbatim.txt):
…API_KEY=CANARY7F3A9-SECRET-should-not-leave\nDB_PASSWORD=CANARY7F3A9-DBPASS\n…"model":"grok-4.5"…
Repro: grep -a "CANARY7F3A9-DBPASS" secrets_responses_body.bin → matches. All six file markers (source, logic, README, nested JS, API key, DB password) are recoverable from the decrypted /v1/responses bodies. (This artifact proves the secret was transmitted to the /v1/responses endpoint; the raw body file does not carry the response status, so the acceptance (200) claim is anchored to the /v1/storage channel immediately below, which is status-mapped in wire_12gb.log.)
Second channel — persisted to Google Cloud Storage. The same content is packaged into a session_state archive uploaded via POST /v1/storage. Proven by decompressing the staged artifact before it drains (appendix: secrets_session_state.tar.gz):
gzip -dc secrets_session_state.tar.gz | tar -xO | grep -ao 'CANARY7F3A9-[A-Z]*'
→ CANARY7F3A9-SECRET, CANARY7F3A9-DBPASS, + all others
So the secret is not only processed in-flight; it is written into an archive destined for storage.
Pre-empting "you told it to read the secrets." A control run with a file the agent was told not to open (untouched_secret.txt) and the prompt "Reply exactly OK, do not read any files" produced no occurrence of that file's marker in any captured body. The leak is therefore scoped to files Grok does read — but it reads liberally (any file relevant to the task, including a .env) and applied no redaction to that file's contents. The defect is that a secrets file was transmitted unredacted, not the act of reading. Important scope reconciliation: this control shows the unread file is absent from the /v1/responses bodies — that is Channel A (files the agent reads). It does not clear the separate whole-repo /v1/storage snapshot in §4 (Channel B), which — by the volume evidence there — does sweep in never-read files; I could not decompress the /v1/storage codebase chunk to check this specific file. So "unread file not uploaded" is true only for the model-turn channel, not for the codebase snapshot. (Two further scope notes: (i) in my runs the .env/secrets.env was git-tracked; I did not separately test whether a .gitignored file is still uploaded, so I make no gitignore claim — the mechanism is read-driven per the file_access_tracker crate, but that specific case is untested. (ii) The canary values sat in API_KEY=/DB_PASSWORD= keys inside a .env/secrets.env but were not real-format high-entropy tokens; I proved this .env was transmitted unredacted, not that no redactor exists for, say, an sk-…-shaped key.)
4. Finding 2 — The whole repo is uploaded at multi-GB scale; the only ceiling is a model quota, not storage size
Claim: Grok uploads a whole-repo snapshot with no storage size wall in the tested range. As the repo grows it switches upload strategy and keeps returning 200; on a 12 GB repo, 73 chunks of ~75.0 MB (5.10 GiB) uploaded with zero failures before the capture was truncated mid-stream.
Wire-captured size sweep (incompressible content so the tar cannot shrink; fresh session each step). Only the 12 GB row was retained as a file (wire_12gb.log); the smaller rows were observed live during the sweep but their logs were not saved (see §7):
| Repo size | Upload behavior (observed on the wire) | Status | Artifact |
|---|---|---|---|
| 64 MB | single POST /v1/storage, req=50548145b (48 MB) | 200 | observed, not retained |
| ~600 MB | POST /v1/storage in ~7.5 MB chunks (dozens) | all 200 | observed, not retained |
| ~3 GB | POST /v1/storage/multipart/init → PUT storage.googleapis.com/grok-code-session-traces/multipart/<id> in 50 MB parts (direct-GCS PUT lines not preserved — §7) | all 200 | observed, not retained |
| ~12 GB | POST /v1/storage in 75 MB chunks (req≈75014840b); 73 chunks (~5.1 GB) captured before I stopped the run | all 200, 0 failures | wire_12gb.log |
Preserved artifact: wire_12gb.log (appendix). It contains 83 /v1/storage* 200 responses: 82 content-upload POST …/v1/storage requests — of which 73 are chunks of ~75.0 MB each (byte sizes min 75,014,811 / max 75,014,871, totaling 5,476,083,317 B = 5.10 GiB / 5.48 GB) plus 9 smaller POSTs — and 1 /v1/storage/batch_exists dedup check. Total /v1/storage* request bytes: 5,476,228,005 B. Zero storage requests failed. The capture was stopped while uploads were still streaming (the last line is another ~75 MB chunk → 200), so this demonstrates ≥5.1 GiB uploaded, still climbing when truncated — not that the full 12 GB completed. Repro (three greps, so no count is ambiguous): grep 'cli-chat-proxy.grok.com/v1/storage' wire_12gb.log | grep -c '> 200' → 83 (all /v1/storage*); grep 'POST cli-chat-proxy.grok.com/v1/storage ' wire_12gb.log | grep -c '> 200' → 82 (content POSTs only); grep req=75014 wire_12gb.log | grep -c '> 200' → 73 (the ~75 MB chunks).
Scope — this is the whole repo, not just files the agent read. Channel A (§3, /v1/responses) carries files the agent opens. This §4 upload is a separate Channel B: a snapshot of the entire workspace. Two lines of evidence:
- (a) The decisive byte split (load-bearing). In the same captured 12 GB session — a repo of 100 % random files the agent never read — the two channels moved wildly different volumes:
- Channel A
/v1/responses(model turns): 196,705 B = 192 KB total, across 5 requests, largest single turn 60,394 B. - Channel B
/v1/storage: 5,476,228,005 B = 5.10 GiB. - That is a ~27,800× ratio (5,476,083,317 ÷ 196,705). The model demonstrably never ingested the files (192 KB cannot carry 5 GiB of content), yet 5.10 GiB of them left via
/v1/storage— and across the sweep the/v1/storagevolume tracks total repo size (64 MB → 12 GB). GB-scale bytes leaving a never-read repo can only be a whole-repo snapshot.
- Channel A
- (b) The binary's own paths/strings corroborate the mechanism:
after_codebase.tar.gz,xai-grok-shell/src/upload/{trace,turn}.rs,repo_state.upload, "collecting workspace files", "spawning background coordinator". - (c) A staged codebase manifest enumerates a never-read file and content-addresses it to the GCS bucket. In a separate run on a real 298-file
cereblab_apicodebase, I race-copied the snapshot Grok staged for upload (appendix:staged_base_tree_manifest.json,staged_metadata.json). The manifest listssrc/_probe/never_read_canary.txt— a file I planted and never asked Grok to open — alongside the real sources (src/apns.ts,src/index.ts, …); andmetadata.jsongives each file a destinationfileIdof the formgs://grok-code-session-traces/repo_changes_dedup/v2/supplemental/sha256_…. So the whole tree, unread files included, is enumerated and content-addressed for upload to the named GCS bucket. (That run was quota-blocked before the content blobs uploaded; the 12 GB run above independently proves GB-scale content actually leaves the wire, all 200.) - (d) DEFINITIVE — a never-read file's content recovered verbatim from an uploaded git bundle (single SuperGrok run). With the account upgraded (quota lifted), I re-ran on the real
cereblab_apirepo with the explicit promptReply with exactly: OK. Do not read or open any files., having planted a unique markerCANARY-XR47P2-NEVERREAD-UNIQUEinsrc/_probe/never_read_canary.txt. Grok uploaded the entire repository as a git bundle viaPOST cli-chat-proxy.grok.com/v1/storage → 200 (req=152102b)(appendixuploaded_repo.bundle, SHA-25673b9c0af06311bae35c3ed03274d0eec2846e76762828d10b09757ca41bd6024). Runninggit clone uploaded_repo.bundlereconstructs the repo, andsrc/_probe/never_read_canary.txtcontains the marker verbatim — a file the agent was explicitly told not to open. The bundle also carries the full git history (4 commits, 47 files). This is the airtight per-file-content proof: the whole repo — unread files included — left the machine and was accepted (200). The upload mechanism is a git bundle, so "whole repo" is literal (every tracked file + history). Replicated on a second, unrelated codebase: the identical capture on thecereblab_authCloudflare-Worker repo produced a git-bundle upload (POST /v1/storage → 200, 31,743 B) from whichgit clonerecovered its own never-read markerCANARY-AUTH-4T8K2-NEVERREADverbatim (appendixuploaded_repo_auth.bundle, SHA-2560ee536538bcd1ee72a258f9977ab69f8a9b1ac240491b91a4e94335b4d83c768). Two independent repos, same result.
(Prompt note: the 12 GB session was interactive and I did not log its verbatim prompt, but the 192 KB Channel-A total is dispositive that no bulk read occurred whatever the prompt was; a separate headless control run used the explicit prompt Reply exactly OK, do not read any files and confirmed an unread file is absent from Channel A.)
(The earlier "one gap" is now closed by evidence (d): a single SuperGrok run where a specific never-read file's content is recovered from a wire-captured, 200-status git-bundle upload. The 12 GB run remains the proof that this scales to GB volumes.)
No storage/upload request failed — every one of the 82 /v1/storage calls returned 200. The only non-200s in the entire capture were on the model endpoint plus one session-bookkeeping call (full set from wire_12gb.log; /v1/responses lines also in model_limit.txt):
POST /v1/responses -> 402 (Payment Required) ×1
POST /v1/responses -> 429 (Too Many Requests) ×3
POST /v1/sessions/<id>/replicas/update -> 404 ×1 (session bookkeeping, not an upload)
and finally, in plain text, on stdout:
You've reached your free Grok Build usage limit for now. Get SuperGrok for much higher limits…
The 402/429 are a model-usage quota; the lone 404 is unrelated to storage. Notably, storage uploads continued to return 200 after the model turn was rate-limited (76 /v1/storage 200s occur at or after the first 429) — the codebase upload is independent of whether the model answers.
Pre-empting "you're confusing a local disk cache with an upload." This claim rests strictly on wire-captured 200-status uploads of file bytes leaving the machine (/v1/storage request bodies of 7.5–75 MB in the preserved wire_12gb.log; the 3 GB 50 MB PUTs to storage.googleapis.com were also seen on the wire, but that log was not retained — §7). It does not rely on the ~/.grok/upload_queue draining — queue-drain is ambiguous (it empties on both success and drop) and is explicitly not used as evidence here. (An earlier draft that inferred upload from queue-drain was wrong and has been retracted; see §7.)
5. Finding 3 — Destination, telemetry, and what's not surfaced in the docs
- Storage destination is Google Cloud Storage, bucket
grok-code-session-traces. This rests on the preserved binary stringsgrok-code-session-traces,storage.googleapis.com, and "Uploading bytes to GCS via proxy" (crate_strings.txt), and on a preserved stagedmetadata.jsonwhose per-filefileIds are literallygs://grok-code-session-traces/repo_changes_dedup/v2/…/sha256_…(staged_metadata.json), corroborated by the directstorage.googleapis.commultipart PUTs observed at 3 GB (observed live; that log was not retained — see §7). It is not AWS S3 (the binary linksaws-sdk-s3for an alternate path and AWS STS/SSO for auth, but the destination named in the binary — and seen on the wire at 3 GB — is GCS). - Third-party telemetry:
POST api.mixpanel.com/trackand/engage(Mixpanel), plusPOST grok.com/_data/v1/events— all 200. - Not surfaced in setup docs (scope-limited claim): I did not find the
repo_state/session_stateupload togrok-code-session-traces, or the~/.grok/upload_queuestaging, described in the CLI's install script or quickstart materials I reviewed (this is not an exhaustive audit of all xAI docs — see §7). The mechanism is active by default on the standard consumer login. - Reliability note (separate from privacy):
~/.grok/upload_queuestages ~3 GB snapshots per turn and, under load, can grow to tens of GB and exhaust the disk. This is a real bug, independent of whether uploads succeed.