Grok CLI Whole-Repo Upload Reverse Analysis: How a Single Prompt Ships Your .env to the Cloud
How It Started
It started when Shan ge caught wind of a community rumor: grok cli might be risky. What exactly the risk was, and how bad, the community couldn’t agree on. I happened to have the new 0.2.98 macOS aarch64 binary on hand (SHA-256 d5952131...), so I decided to dig in myself — rather than speculate, I’d rather tear the binary apart, capture the traffic, and recover the uploaded content to see for myself.
The conclusion turned out more concrete than the rumors: at the start of each turn, Grok CLI uploads the entire codebase via git bundle — including the .env, .envrc, and config.secret that .gitignore excludes — verbatim and unredacted to xAI’s cloud storage. The upload happens before the model receives the inference request, is fully independent of the “Improve the model” toggle, and the server can force-enable it via remote settings. I used IDA Pro 9.3 for static decompilation, Frida for runtime byte verification, and mitmproxy 12.2.3 for traffic capture — three-way cross-verification — and compared against the 0.2.93 version the community had already analyzed. The upload mechanism is identical across both versions.
Since Grok CLI is not open source, without access to the source code, information can only be gathered through decompilation and dynamic debugging — and it’s difficult to guarantee 100% accuracy. In this analysis I used static analysis, dynamic debugging, and traffic capture for cross-verification to keep the conclusions as close to truth as possible. Below is what the reproduction uncovered.
Reproduction Environment
I set up a minimal test project specifically to verify the upload behavior:
/tmp/grok-test-project/
├── .env ← 6 fake API keys
├── .envrc ← 2 keys
├── config.secret ← RSA private key
├── .gitignore ← excludes .env, .envrc, *.secret
├── src/main.py ← print("hello")
└── README.md ← # Test Project
.env held 6 fake API keys (OpenAI, Anthropic, database, AWS, Stripe, JWT), .envrc held 2, and config.secret held a fake RSA private key. .gitignore explicitly excluded .env, .envrc, and *.secret — this is key, because if Grok CLI respected .gitignore, those three files should never be uploaded.
For the Grok config (~/.grok/config.toml), I used the default config to observe the full upload behavior under the most controlled conditions:
[features]
telemetry = false -> default config
[telemetry]
trace_upload = false -> default config
[harness]
disable_codebase_upload = false -> default config, after 2026.07.13 the config returned by the server changed to true — this switch is server-controlled!
On the mitmproxy side, Grok CLI uses rustls 0.23.37 (statically compiled with webpki-roots), bypasses the macOS keychain, and has no certificate pinning (searching cert.*pin|pinned returns nothing). So pointing SSL_CERT_FILE at the mitmproxy CA certificate decrypts all HTTPS traffic:
export GROK_DEPLOYMENT_KEY="fake-deployment-key-for-testing"
export GROK_RESPECT_GITIGNORE=0
export GROK_SANDBOX=none
export HTTPS_PROXY="http://127.0.0.1:8080"
export SSL_CERT_FILE="$HOME/.mitmproxy/mitmproxy-ca-cert.pem"
The prompt sent was simple: Hello World!.
One Prompt, 45 Requests
After the prompt went out, mitmproxy captured 45 HTTP requests within 5.2 seconds. Of those, 14 were POST /v1/storage — upload requests.
The most striking part is the timeline. Upload is not a byproduct of the model call; it’s a prerequisite step that precedes it:
18:31:29.882 POST /v1/storage config.json (4,086B) ← upload begins
18:31:29.883 POST /v1/storage config_files.json (1,485B)
18:31:29.895 POST /v1/storage plugins.json (42B)
18:31:29.896 POST /v1/storage tool_definitions.json (45,440B)
18:31:29.898 POST /v1/storage metadata.json (913B)
18:31:29.901 POST /v1/storage before_session_state.tar.gz (4,689B)
18:31:29.905 POST api.x.ai/v1/responses (1,252B) ← model call (4ms later)
18:31:30.179 ← model returns 403 (no credits)
18:31:30.217 ← upload returns 401 (forged key)
18:31:30.458 POST /v1/storage git bundle (1,120B) ← codebase upload
18:31:33.815 POST /v1/traces (18,272B) ← OpenTelemetry trace
The 6 initial uploads completed within 19 milliseconds (29.882 → 29.901), and only then did the model call begin (29.905). Upload started 4 milliseconds before the model call. In other words, the user’s code was sent to the server before the model even received the inference request — upload is an independent prerequisite, not a side effect of the model call.
What’s stranger: even when the model returned 403 (account has no credits), upload requests kept firing; even when uploads returned 401 (I used a forged deployment key), the upload queue kept retrying until a circuit breaker intervened:
Upload queue circuit breaker tripped, pausing dispatch
Recovering Sensitive Files From the Traffic
This is the one step in the whole analysis I most wanted people to see with their own eyes.
Among the 14 upload requests, I found one whose Content-Type was application/gzip and whose x-storage-path pointed to turn_0/repo_changes_dedup/v2/bundles/sha256_84aa...bundle. This isn’t a file summary — it’s a standard git bundle that can be directly git clone-d to recover the full repository history.
Extracting it from the traffic, then verifying and cloning with native git commands:
$ git bundle verify /tmp/captured.bundle
The bundle contains 1 ref:
refs/heads/main
/tmp/captured.bundle is okay
$ git clone /tmp/captured.bundle grok_bundle_clone
Clone succeeded, recovering 6 files: .env, .envrc, config.secret, .gitignore, README.md, src/main.py. Three of those were explicitly excluded by .gitignore.
The contents of .env, verbatim and unredacted:
OPENAI_API_KEY=sk-test-key-1234567890abcdef
ANTHROPIC_API_KEY=sk-ant-test-key-abcdef123456
DATABASE_URL=postgresql://user:password@localhost:5432/db
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
STRIPE_SECRET_KEY=sk_live_test_stripe_secret_key_12345
JWT_SECRET=my-super-secret-jwt-key-1234567890
6 API keys, 2 environment-variable secrets, and an RSA private key — all sitting in the git bundle as-is. The redaction filter (had it worked) should have replaced these with [REDACTED_SECRET] — but it didn’t.
Why the Redaction Filter Didn’t Catch It
Grok CLI does have a built-in redaction system. From IDA, I extracted the full set of 12 regexes (based on Rust’s regex::RegexSet): Bearer tokens, GitHub tokens, GitLab/Slack tokens, Stripe/xAI keys, AWS keys, Google API keys, PEM private keys, JWTs, user home-directory paths… The coverage isn’t narrow, and matches are replaced with [REDACTED_SECRET].
But this redaction only applies to trace logs and key-value pairs in JSON metadata. The source path is xai-grok-shell/src/upload/trace.rs. The git bundle upload, on the other hand, takes a different path — xai-data-collector/src/queue.rs, an independent upload queue.
So a split emerges: the same sk-test-key-1234567890abcdef in .env gets redacted to [REDACTED_SECRET] in the trace log, but is uploaded as-is in the git bundle. Two paths, two treatments. Of the 12 categories of content uploaded in a single turn, the git bundle is the only one carrying raw file contents — and it happens to be the only one that doesn’t pass through redaction.
Discovering 8 Independent Switches
trace_upload’s on/off isn’t controlled by a single switch. IDA reveals a contiguous string block that explicitly lists 8 independent enable sources.
The last source, in_remote_trace_upload_enabled, combined with has_remote_settings, seems to mean the xAI backend can force-enable uploads via remote configuration — even if the user has turned off every environment variable and config file locally. The config priority is env > config > remote, but remote settings are themselves an independent enable source.
More importantly, trace_upload is fully independent of the “Improve the model” toggle in the UI (coding_data_retention_opt_out). Turning off “Improve the model” does not disable trace_upload. A log line in the binary says it bluntly:
Telemetry disabled but trace uploads enabled: session artifacts will be uploaded, analytics events will not
respect_gitignore Defaults to false
Why were .gitignore-excluded files still uploaded? Because respect_gitignore defaults to false.
Disassembling the 0.2.98 config-parsing function in IDA at address 0x10372EB5C:
loc_10372EB5C:
MOV W27, #0 ; respect_gitignore default = 0 (false)
0x10372EBAC:
AND W8, W27, #1 ; extract boolean bit
0x10372EBB0:
STRB W8, [X19, #0xC8] ; write to struct offset 0xC8
0x10372EBC4:
ADRL X0, aGrokRespectGit ; load "GROK_RESPECT_GITIGNORE"
MOV W27, #0 sets the default to 0 (false). To rule out IDA analysis error, I spawned the process with Frida and read the runtime memory directly:
var base = Process.enumerateModules()
.find(m => m.name === "grok").base;
// base = 0x100f0c000, ASLR slide = 0xf0c000
// IDA address 0x10372EBB0 + slide = 0x10463abb0
var bytes = Memory.readByteArray(ptr("0x10463abb0"), 4);
// returns: 68 22 03 39 (little-endian)
The bytes read are 68 22 03 39, which decodes little-endian to 0x39032268 — the ARM64 instruction STRB W8, [X19, #0xC8], exactly matching the IDA disassembly. The embedded help text in the binary also states it plainly: respect_gitignore = false # default: false.
Defaulting to false means Grok CLI does not honor .gitignore when creating the git bundle. That’s the direct reason .env, .envrc, and config.secret showed up in the upload.
(I performed the same verification on 0.2.93.)
Version Comparison
Community researcher cereblab previously analyzed 0.2.93 in a Gist, listing 10 claims. I verified each one against 0.2.98.
Searching 0.2.98 with IDA find_regex and 0.2.93 with strings | grep, the following are identical across both versions: the 8 trace_upload sources, the 12 redaction regexes, the 8 storage endpoints, the circuit-breaker states, the GCS bucket grok-code-session-traces, the GROK_TELEMETRY_GCS_BUCKET environment variable, and the absence of certificate pinning.
0.2.98 is 1.83MB larger than 0.2.93, with 6 additional import symbols — all display-system related: CGDisplayCopyDisplayMode, CGDisplayModeGetRefreshRate, CGMainDisplayID… The corresponding string grok-shell-display_refresh_probe confirms this is a display-refresh-rate probing feature. The size increase has nothing to do with the upload mechanism.
(As an aside, mitmproxy also caught 0.2.98 automatically checking x.ai/cli/stable for the latest version number, detecting 0.2.101, and then auto-downloading the new binary in 7 chunks. Auto-update itself isn’t surprising, but it means even if you fully analyze the version in hand today, it might swap tomorrow — auto-updates can introduce new features, but also new risks.)
Mitigations
If you want to keep using Grok CLI while minimizing data upload, use three layers in combination — because remote settings can override local config, a single layer won’t cut it. See the note on the left for specifics.
Closing Thoughts
What struck me most in this analysis wasn’t any single vulnerability, but the “consistency gap” — the redaction system exists but doesn’t cover git bundle; one of the 8 switches is remotely controllable; “Improve the model” is off but trace_upload keeps transmitting. Each one individually has an explanation (redaction is for traces, remote settings are for operations, “Improve the model” governs something else), but stitched together, the user’s .env and other sensitive data end up uploaded to the cloud.
The privacy boundary of a CLI coding tool is, at its core, a trust boundary. Handing your entire repo to a binary that packages and uploads at the start of every turn means placing trust in every one of its switches, every upload path, every remote-config push. And trust should be continuously verified, not granted once.
Stay safe out there.
This article is based on cross-verification with IDA Pro 9.3 decompilation, Frida dynamic instrumentation, and mitmproxy 12.2.3 traffic capture. The analyzed binary versions are grok-0.2.98 and grok-0.2.93 (macOS aarch64). References: cereblab’s 0.2.93 analysis and grok-build-privacy-hardening.