跳到正文
北京时间
原文
Hacker News 热门(buzzing.cc 中文翻译)· jhoho·· 2026-07-12精选AI 评分74

xAI Grok Build CLI 网络流量分析:上传仓库全部文件及 git 历史

xAI 的 Grok Build CLI 实际上向 xAI 发送了什么

AI 导读

对 xAI 官方 Grok Build 编码 CLI(grok 0.2.93)的网络流量分析显示,该工具在消费者登录后会向 xAI 发送三类数据:一是它读取的文件内容(包括 .env 密钥文件)以明文形式通过 POST /v1/responses 传输,并同时打包成 session_state 存档通过 POST /v1/storage 上传并获 HTTP 200 确认;二是整个仓库的全部文件内容及 git 历史,独立于 AI 智能体实际读取的文件——即使提示“不要读取任何文件”,Grok 仍将整个仓库作为 git bundle 上传至 Google Cloud Storage 的 grok-code-session-traces 存储桶;三是该上传机制默认开启,且关闭“改进模型”设置不会禁用(/v1/settings 仍返回 trace_upload_enabled: true)。在 12 GB 仓库测试中,/v1/storage 传输了 5.10 GiB 数据,而模型对话通道仅传输 192 KB,比例约 27,800 倍。分析未证明 xAI 使用这些数据进行训练,但证实了数据被传输、接收并存储。

推荐理由

这是我见过最严谨的隐私调查,每步可复现——Grok CLI 会在用户不知情下将完整仓库、.env 密钥甚至未读文件原样上传至 xAI 的 GCS,默认开启且无法真正关闭,所有用 Grok Build 的开发者都得重审自己的 secrets。

正文 · 原文

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:

  1. It transmits the contents of files it reads — including a .env secrets file — to xAI, verbatim and unredacted. The secret appears in two channels: the live model turn (POST /v1/responses) and a session_state archive uploaded and accepted (HTTP 200) via POST /v1/storage — the endpoint the binary routes to the grok-code-session-traces GCS bucket (see §5).
  2. 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 (appendix uploaded_repo.bundle). And it scales: on a 12 GB repo of never-read random files, /v1/storage moved 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/responses and one unrelated 404 — not a storage size cap.
  3. The storage destination is a Google Cloud Storage bucket, grok-code-session-traces (not AWS S3) — named verbatim in the binary and in a captured metadata.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/settings still returned trace_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.

  1. brew install mitmproxy; run once to generate its CA at ~/.mitmproxy/.
  2. 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
    
  3. Run Grok routed through the proxy (a mitmdump addon 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>
    
  4. For staged-artifact inspection, race-copy ~/.grok/upload_queue/* during the run, then gzip -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/storage volume tracks total repo size (64 MB → 12 GB). GB-scale bytes leaving a never-read repo can only be a whole-repo snapshot.
  • (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_api codebase, I race-copied the snapshot Grok staged for upload (appendix: staged_base_tree_manifest.json, staged_metadata.json). The manifest lists src/_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, …); and metadata.json gives each file a destination fileId of the form gs://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_api repo with the explicit prompt Reply with exactly: OK. Do not read or open any files., having planted a unique marker CANARY-XR47P2-NEVERREAD-UNIQUE in src/_probe/never_read_canary.txt. Grok uploaded the entire repository as a git bundle via POST cli-chat-proxy.grok.com/v1/storage → 200 (req=152102b) (appendix uploaded_repo.bundle, SHA-256 73b9c0af06311bae35c3ed03274d0eec2846e76762828d10b09757ca41bd6024). Running git clone uploaded_repo.bundle reconstructs the repo, and src/_probe/never_read_canary.txt contains 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 the cereblab_auth Cloudflare-Worker repo produced a git-bundle upload (POST /v1/storage → 200, 31,743 B) from which git clone recovered its own never-read marker CANARY-AUTH-4T8K2-NEVERREAD verbatim (appendix uploaded_repo_auth.bundle, SHA-256 0ee536538bcd1ee72a258f9977ab69f8a9b1ac240491b91a4e94335b4d83c768). 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 strings grok-code-session-traces, storage.googleapis.com, and "Uploading bytes to GCS via proxy" (crate_strings.txt), and on a preserved staged metadata.json whose per-file fileIds are literally gs://grok-code-session-traces/repo_changes_dedup/v2/…/sha256_… (staged_metadata.json), corroborated by the direct storage.googleapis.com multipart PUTs observed at 3 GB (observed live; that log was not retained — see §7). It is not AWS S3 (the binary links aws-sdk-s3 for 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/track and /engage (Mixpanel), plus POST grok.com/_data/v1/events — all 200.
  • Not surfaced in setup docs (scope-limited claim): I did not find the repo_state / session_state upload to grok-code-session-traces, or the ~/.grok/upload_queue staging, 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_queue stages ~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.

6. Consent and policy — stated honestly

来源:Hacker News 热门(buzzing.cc 中文翻译) · gist.github.com