Security Research

Grok CLI Whole-Repo Upload Reverse Analysis: How a Single Prompt Ships Your .env to the Cloud

#Sample Analysis#Code Audit#Supply Chain Attack#Developer Security
Over a near-black teal-blue background, a captured git bundle unpacks within intercepted network traffic as plaintext credentials and cryptographic material spill out intact and ungoverned by any redaction filter; the foreground shows a clean developer-tool facade concealing a background upload conduit dispatching the entire repository ahead of the model's inference call; the midground features a parallel conduit bypassing a security filter with raw sensitive data flowing unchecked through the ungoverned channel — copper-orange accents punctuate the scene, symbolizing the Grok CLI whole-repo upload reverse analysis

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.