Skip to content

Commit 7ad054b

Browse files
lroolleclaude
andcommitted
fix(grok): updater isolation, api-key no-mount, entrypoint coverage
Review hardening for #404 (thanks Codex + two deep review passes): - tmpfs over ~/.grok/bin and ~/.grok/downloads whenever a host dir is mounted at /home/deva/.grok. Reproduced against real 0.2.93: a mounted config.toml without postinstall's `[cli] installer = "npm"` marker flips the updater to installer=internal, and `grok update` re-creates both dirs ON THE HOST MOUNT — a Linux binary that shadows a macOS host CLI via the host npm trampoline. Updater writes now die with the container; verified live (mount stack + host-side clean). - api-key mode mounts no ~/.grok, per the #403 spec. grok credential priority is model.api_key > model.env_key > session token > XAI_API_KEY, so anything a mounted config carries can outrank — and bill instead of — the exported key. Blank auth.json overlay kept as defense for explicit -v mounts. - docker-entrypoint.sh: grok version header, verbose diagnostics, and binary presence check (#403 scope, was missing). - test-mount-shape.sh: grok in hybrid/default loops; new assertions for the update guard and api-key no-mount. - docs/authentication.md, CHANGELOG, DEV-LOGS: correct the false "grok update is a no-op" claim; document the real behavior. Refs #403 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 93a59e0 commit 7ad054b

7 files changed

Lines changed: 168 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818
fourth first-party agent (#403). `deva.sh grok` runs it YOLO-style
1919
(`--always-approve`) in the standard container shape. Auth: `oauth`
2020
(default, mounts `~/.grok`; in-container first login via
21-
`grok login --device-auth`) or `api-key` (`XAI_API_KEY`). In api-key mode
22-
the default `auth.json` is masked with a blank overlay because grok
23-
prefers a session token over the env key. Grok's npm launcher resolves
24-
`~/.grok/bin/grok` (its self-update dir) before the packaged binary, so
25-
the image moves the real binary to `~/.local/bin` and drops `~/.grok/bin`
26-
— a host-mounted `~/.grok`, possibly holding a macOS binary, can never
27-
shadow the container CLI. Pin:
21+
`grok login --device-auth`) or `api-key` (`XAI_API_KEY`). api-key mode
22+
mounts no `~/.grok` at all: grok's credential priority (`model.api_key` >
23+
`model.env_key` > session token > `XAI_API_KEY`) means anything a mounted
24+
config carries could outrank — and bill instead of — the exported key.
25+
Grok's npm launcher resolves `~/.grok/bin/grok` (its self-update dir)
26+
before the packaged binary, so the image moves the real binary to
27+
`~/.local/bin` and drops `~/.grok/bin` — a host-mounted `~/.grok`,
28+
possibly holding a macOS binary, can never shadow the container CLI.
29+
The reverse is guarded with container-local tmpfs over `~/.grok/bin` and
30+
`~/.grok/downloads` whenever a host dir is mounted there: in-container
31+
`grok update` cannot write Linux binaries into the host install. Pin:
2832
`GROK_CLI_VERSION` in `versions.env`, label
2933
`org.opencontainers.image.grok_cli_version`, wired through make/CI/
3034
nightly/release version plumbing

DEV-LOGS.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@
1717
- Why: xAI shipped an official coding-agent CLI (x.ai/cli, npm @xai-official/grok); deva should launch it like claude/codex/gemini. Not to be confused with community superagent-ai/grok-cli (npm grok-dev) — different product, same bin name.
1818
- What:
1919
- agents/grok.sh: oauth default (mount ~/.grok) + api-key (XAI_API_KEY); appends --always-approve (grok's bypassPermissions alias) since the container is the sandbox
20-
- deva.sh: canonical entry .grok, autolink ~/.grok, XAI_API_KEY env handling, blank-overlay /home/deva/.grok/auth.json for api-key mode (grok prefers session token over env key — overlay makes the key actually win)
20+
- deva.sh: canonical entry .grok, autolink ~/.grok, XAI_API_KEY env handling. api-key mode mounts NO ~/.grok (grok credential priority: model.api_key > model.env_key > session token > XAI_API_KEY — anything a mounted config carries can outrank the exported key; per #403 spec and Codex review). Blank auth.json overlay kept as defense for explicit -v mounts
2121
- install-agent-tooling.sh pin_grok_platform_binary: grok's npm bin is a trampoline that resolves ~/.grok/bin/grok FIRST, and postinstall parks the ~125MB real binary there (self-update dir); a host mount would shadow it (macOS binary -> exec format error). Move the binary to ~/.local/bin, repoint the npm bin, rm ~/.grok/bin
22-
- GROK_CLI_VERSION pin wired through versions.env, Makefile, Dockerfile(+rust), version-pins/upgrade/report/resolve scripts, ci/nightly/release workflows; tests extended (release-utils registry, version-upgrade mock, install-tooling, version-targets)
23-
- Result: deva.sh grok works with both auth modes (dry-run verified: mount + env + overlay wiring); grok pinned at 0.2.93; make versions-up picks up new upstream releases
22+
- deva.sh append_grok_update_guard: the reverse hole, found in review and reproduced against the real 0.2.93 — a mounted config.toml without the postinstall's `[cli] installer = "npm"` marker flips the updater to installer=internal, and `grok update` re-creates ~/.grok/bin + ~/.grok/downloads ON THE HOST MOUNT (Linux binary shadows a macOS host CLI via the host trampoline). Fix: tmpfs over both dirs whenever a host dir lands at /home/deva/.grok; updater writes die with the container
23+
- docker-entrypoint.sh: grok version header, verbose diagnostics, ensure_agent_binaries
24+
- GROK_CLI_VERSION pin wired through versions.env, Makefile, Dockerfile(+rust), version-pins/upgrade/report/resolve scripts, ci/nightly/release workflows; tests extended (release-utils registry, version-upgrade mock, install-tooling, version-targets, mount-shape grok + update-guard + api-key no-mount assertions)
25+
- Result: deva.sh grok works with both auth modes (dry-run + mount-shape verified); grok pinned at 0.2.93; make versions-up picks up new upstream releases; host ~/.grok cannot be poisoned by in-container updates (tmpfs guard verified live: uid/gid/mode/size + noexec)
2426

2527
# [2026-07-07] Dev Log: switch --trace default from claude-trace to cctrace
2628
- Why: claude-trace's node --require fetch hook only sees /v1/messages and dies on native Claude binaries; cctrace (thevibeworks/cctrace) MITM-captures everything (OAuth, usage/credits, MCP) and auto-detects npm vs native installs

agents/grok.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ setup_grok_auth() {
5252
"Set: export XAI_API_KEY=your_key (from https://console.x.ai)"
5353
fi
5454

55+
# No ~/.grok mount in this mode (grok_api_key_no_mount in
56+
# deva.sh): grok resolves config credentials above XAI_API_KEY,
57+
# so a mounted config.toml could silently bill another account.
5558
AUTH_DETAILS="api-key (XAI_API_KEY)"
5659
DOCKER_ARGS+=("-e" "XAI_API_KEY=$XAI_API_KEY")
5760
;;

deva.sh

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1976,13 +1976,49 @@ agent_canonical_basenames() {
19761976
esac
19771977
}
19781978
1979+
# grok api-key contract (#403): pass XAI_API_KEY, mount no auth dir.
1980+
# grok's credential priority is model.api_key > model.env_key > session
1981+
# token > XAI_API_KEY, so anything a mounted ~/.grok carries (config.toml
1982+
# per-model keys, auth.json session) can outrank the exported key and
1983+
# silently bill another account.
1984+
grok_api_key_no_mount() {
1985+
[ "$ACTIVE_AGENT" = "grok" ] && [ "${AUTH_METHOD:-}" = "api-key" ]
1986+
}
1987+
1988+
# grok's self-updater writes Linux binaries into ~/.grok/bin and
1989+
# ~/.grok/downloads — inside the auth dir we bind-mount. Verified against
1990+
# @xai-official/grok 0.2.93: a mounted config.toml without the npm installer
1991+
# marker flips the updater to installer=internal, and `grok update` then
1992+
# re-creates both dirs on the mount (worst case: Linux binary shadowing a
1993+
# macOS host CLI via the host npm trampoline). Whenever a host dir lands at
1994+
# /home/deva/.grok, overlay those two paths with container-local tmpfs so
1995+
# updater writes die with the container instead of poisoning the host.
1996+
append_grok_update_guard() {
1997+
local arg
1998+
for arg in "${DOCKER_ARGS[@]+"${DOCKER_ARGS[@]}"}"; do
1999+
case "$arg" in
2000+
*:/home/deva/.grok | *:/home/deva/.grok:*)
2001+
DOCKER_ARGS+=(
2002+
--tmpfs "/home/deva/.grok/bin:uid=$(id -u),gid=$(id -g),mode=0755,size=512m"
2003+
--tmpfs "/home/deva/.grok/downloads:uid=$(id -u),gid=$(id -g),mode=0755,size=512m"
2004+
)
2005+
return 0
2006+
;;
2007+
esac
2008+
done
2009+
return 0
2010+
}
2011+
19792012
# Mount the canonical entries for one agent from a source directory.
19802013
# Host-side CLI -v or .deva VOLUME= at the same container target wins
19812014
# (user_volumes_declares_target suppression; first-writer-wins holds).
19822015
mount_agent_canonical() {
19832016
local agent="$1"
19842017
local src_dir="$2"
19852018
[ -n "$agent" ] && [ -d "$src_dir" ] || return 0
2019+
if [ "$agent" = "grok" ] && grok_api_key_no_mount; then
2020+
return 0
2021+
fi
19862022
19872023
local entry src
19882024
while IFS= read -r entry; do
@@ -2092,8 +2128,9 @@ default_credential_target_path() {
20922128
printf '%s' "/home/deva/.gemini/mcp-oauth-tokens-v2.json"
20932129
;;
20942130
grok)
2095-
# grok prefers the auth.json session token over XAI_API_KEY, so the
2096-
# blank overlay is what makes api-key auth win over a mounted ~/.grok.
2131+
# api-key mode mounts no ~/.grok (grok_api_key_no_mount), but an
2132+
# explicit user -v/.deva VOLUME can still carry one in; grok prefers
2133+
# a session token over XAI_API_KEY, so blank-overlay auth.json anyway.
20972134
printf '%s' "/home/deva/.grok/auth.json"
20982135
;;
20992136
*)
@@ -3490,7 +3527,9 @@ else
34903527
[ -f "$HOME/.claude.json" ] && DOCKER_ARGS+=("-v" "$HOME/.claude.json:/home/deva/.claude.json")
34913528
[ -d "$HOME/.codex" ] && DOCKER_ARGS+=("-v" "$HOME/.codex:/home/deva/.codex")
34923529
[ -d "$HOME/.gemini" ] && DOCKER_ARGS+=("-v" "$HOME/.gemini:/home/deva/.gemini")
3493-
[ -d "$HOME/.grok" ] && DOCKER_ARGS+=("-v" "$HOME/.grok:/home/deva/.grok")
3530+
if [ -d "$HOME/.grok" ] && ! grok_api_key_no_mount; then
3531+
DOCKER_ARGS+=("-v" "$HOME/.grok:/home/deva/.grok")
3532+
fi
34943533
fi
34953534
fi
34963535
_step "mount dispatch: done"
@@ -3518,6 +3557,9 @@ if [ "$CONFIG_HOME_FROM_CLI" = false ] && [ "$QUICK_MODE" = false ]; then
35183557
fi
35193558
fi
35203559
3560+
append_grok_update_guard
3561+
_step "append_grok_update_guard"
3562+
35213563
append_user_envs
35223564
_step "append_user_envs"
35233565
validate_bind_mount_shape

docker-entrypoint.sh

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,17 @@ get_codex_version() {
2929
echo "$version"
3030
}
3131

32+
get_grok_version() {
33+
local version=""
34+
for path in "$DEVA_HOME/.local/bin/grok" "$(command -v grok 2>/dev/null)"; do
35+
if [ -x "$path" ]; then
36+
version=$($path --version 2>/dev/null | head -1)
37+
break
38+
fi
39+
done
40+
echo "$version"
41+
}
42+
3243
show_environment_info() {
3344
local header="[deva]"
3445
case "$DEVA_AGENT" in
@@ -46,6 +57,13 @@ show_environment_info() {
4657
header+=" Codex CLI (version detection failed)"
4758
fi
4859
;;
60+
grok)
61+
if [ -n "$GROK_VERSION" ]; then
62+
header+=" Starting Grok ($GROK_VERSION)"
63+
else
64+
header+=" Grok CLI (version detection failed)"
65+
fi
66+
;;
4967
*)
5068
header+=" Starting agent: $DEVA_AGENT"
5169
;;
@@ -87,6 +105,28 @@ show_environment_info() {
87105
else
88106
echo "warning: Claude auth directory not found in $DEVA_HOME/.claude"
89107
fi
108+
elif [ "$DEVA_AGENT" = "grok" ]; then
109+
local grok_path=""
110+
for path in "$DEVA_HOME/.local/bin/grok" "$(command -v grok 2>/dev/null)"; do
111+
if [ -x "$path" ]; then
112+
grok_path="$path"
113+
break
114+
fi
115+
done
116+
if [ -n "$grok_path" ]; then
117+
echo "Grok CLI: $($grok_path --version 2>/dev/null || echo 'Found but version failed')"
118+
echo "Grok location: $grok_path"
119+
else
120+
echo "Grok CLI not found in PATH"
121+
fi
122+
123+
if [ -d "$DEVA_HOME/.grok" ]; then
124+
echo "Grok auth directory mounted"
125+
# shellcheck disable=SC2012
126+
ls -la "$DEVA_HOME/.grok" | head -5
127+
else
128+
echo "warning: Grok auth directory not found in $DEVA_HOME/.grok"
129+
fi
90130
else
91131
local codex_path=""
92132
for path in "/usr/local/bin/codex" "/usr/bin/codex" "$(command -v codex 2>/dev/null)"; do
@@ -306,6 +346,12 @@ ensure_agent_binaries() {
306346
exit 1
307347
fi
308348
;;
349+
grok)
350+
if ! command -v grok >/dev/null 2>&1; then
351+
echo "error: Grok CLI not found in container"
352+
exit 1
353+
fi
354+
;;
309355
esac
310356
}
311357

@@ -316,6 +362,7 @@ main() {
316362

317363
CLAUDE_VERSION="$(get_claude_version)"
318364
CODEX_VERSION="$(get_codex_version)"
365+
GROK_VERSION="$(get_grok_version)"
319366

320367
if [ -n "$ANTHROPIC_BASE_URL" ]; then
321368
case "$ANTHROPIC_BASE_URL" in

docs/authentication.md

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -290,9 +290,15 @@ Requires:
290290

291291
- `XAI_API_KEY` (from [console.x.ai](https://console.x.ai))
292292

293-
Grok prefers an `auth.json` session token over `XAI_API_KEY`, so deva masks
294-
the default `auth.json` path with a blank overlay in this mode. The API key
295-
is what gets billed, even if a mounted `~/.grok` still holds a session.
293+
Grok's credential priority puts mounted config above the environment:
294+
`model.api_key` > `model.env_key` > session token > `XAI_API_KEY`. So in
295+
this mode deva mounts no `~/.grok` at all — the exported key is the only
296+
credential in the container, and it is what gets billed. Session state
297+
lives container-local and does not persist across runs in this mode.
298+
299+
If you force a `~/.grok` mount with `-v` anyway, deva still blank-overlays
300+
the default `auth.json`, but per-model keys in a mounted `config.toml`
301+
outrank `XAI_API_KEY` — don't mix the two.
296302

297303
Example:
298304

@@ -306,9 +312,17 @@ deva.sh grok --auth-with api-key
306312
Grok keeps its real binary in `~/.grok/bin/` (its self-update dir) and the
307313
npm launcher resolves that path first. Inside the image, deva moves the
308314
binary to `~/.local/bin/grok` and removes `~/.grok/bin`, so a host-mounted
309-
`~/.grok` (which may contain a macOS binary) never shadows it. Consequence:
310-
`grok update` inside the container is a no-op for the binary actually being
311-
run; the image pin (`GROK_CLI_VERSION`) is the only version that matters.
315+
`~/.grok` (which may contain a macOS binary) never shadows it.
316+
317+
The reverse direction is guarded too: grok's self-updater writes into
318+
`~/.grok/bin/` and `~/.grok/downloads/`. A mounted `config.toml` without
319+
the npm installer marker makes the updater treat the install as
320+
self-managed, so `grok update` would drop Linux binaries into the host
321+
mount — breaking a macOS host CLI via its npm launcher. Whenever a host
322+
dir is mounted at `/home/deva/.grok`, deva overlays those two paths with
323+
container-local tmpfs: in-container `grok update` works, its writes die
324+
with the container, and the host install stays intact. The image pin
325+
(`GROK_CLI_VERSION`) is the only version that matters.
312326

313327
## Config Homes And Auth Isolation
314328

scripts/test-mount-shape.sh

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,20 +53,24 @@ mkdir -p \
5353
"$hybrid_root/xdg/deva/claude" \
5454
"$hybrid_root/xdg/deva/codex" \
5555
"$hybrid_root/xdg/deva/gemini" \
56+
"$hybrid_root/xdg/deva/grok" \
5657
"$hybrid_root/host/.claude" \
5758
"$hybrid_root/host/.codex" \
5859
"$hybrid_root/host/.gemini" \
60+
"$hybrid_root/host/.grok" \
5961
"$hybrid_root/cli/.codex"
6062
touch "$hybrid_root/host/.claude.json"
6163

6264
ln -sf "$hybrid_root/host/.claude" "$hybrid_root/xdg/deva/claude/.claude"
6365
ln -sf "$hybrid_root/host/.claude.json" "$hybrid_root/xdg/deva/claude/.claude.json"
6466
ln -sf "$hybrid_root/host/.codex" "$hybrid_root/xdg/deva/codex/.codex"
6567
ln -sf "$hybrid_root/host/.gemini" "$hybrid_root/xdg/deva/gemini/.gemini"
68+
ln -sf "$hybrid_root/host/.grok" "$hybrid_root/xdg/deva/grok/.grok"
6669

6770
cat > "$hybrid_root/xdg/deva/.deva" <<EOF
6871
VOLUME=$hybrid_root/host/.codex:/home/deva/.codex
6972
VOLUME=$hybrid_root/host/.gemini:/home/deva/.gemini
73+
VOLUME=$hybrid_root/host/.grok:/home/deva/.grok
7074
VOLUME=$hybrid_root/host/.claude:/home/deva/.claude
7175
VOLUME=$hybrid_root/host/.claude.json:/home/deva/.claude.json
7276
EOF
@@ -94,14 +98,14 @@ count_target() {
9498
fi
9599
}
96100

97-
for agent in claude codex gemini; do
101+
for agent in claude codex gemini grok; do
98102
out="$(run_hybrid "$agent" --dry-run || true)"
99103
if grep -F -- 'duplicate bind mount target detected' <<<"$out" >/dev/null; then
100104
echo "hybrid recipe triggered duplicate-target error for $agent" >&2
101105
echo "$out" >&2
102106
exit 1
103107
fi
104-
for tgt in /home/deva/.claude /home/deva/.claude.json /home/deva/.codex /home/deva/.gemini; do
108+
for tgt in /home/deva/.claude /home/deva/.claude.json /home/deva/.codex /home/deva/.gemini /home/deva/.grok; do
105109
c="$(count_target "$tgt" "$out")"
106110
if [[ "$c" -ne 1 ]]; then
107111
echo "hybrid $agent: target $tgt emitted $c times (want 1)" >&2
@@ -152,7 +156,8 @@ trap default_cleanup EXIT
152156
mkdir -p \
153157
"$default_root/xdg/deva/claude/.claude" \
154158
"$default_root/xdg/deva/codex/.codex" \
155-
"$default_root/xdg/deva/gemini/.gemini"
159+
"$default_root/xdg/deva/gemini/.gemini" \
160+
"$default_root/xdg/deva/grok/.grok"
156161
echo '{}' > "$default_root/xdg/deva/claude/.claude.json"
157162

158163
run_default() {
@@ -167,9 +172,9 @@ run_default() {
167172
) 2>&1
168173
}
169174

170-
for agent in claude codex gemini; do
175+
for agent in claude codex gemini grok; do
171176
out="$(run_default "$agent" --dry-run || true)"
172-
for tgt in /home/deva/.claude /home/deva/.claude.json /home/deva/.codex /home/deva/.gemini; do
177+
for tgt in /home/deva/.claude /home/deva/.claude.json /home/deva/.codex /home/deva/.gemini /home/deva/.grok; do
173178
c="$(count_target "$tgt" "$out")"
174179
if [[ "$c" -ne 1 ]]; then
175180
echo "hybrid-default $agent: target $tgt emitted $c times (want 1)" >&2
@@ -179,6 +184,33 @@ for agent in claude codex gemini; do
179184
done
180185
done
181186

187+
# ───── grok update-guard + api-key no-mount coverage ─────
188+
# A mounted /home/deva/.grok must come with tmpfs overlays for the
189+
# self-updater dirs (bin/, downloads/) so in-container `grok update`
190+
# cannot write into the host mount.
191+
grok_out="$(run_default grok --dry-run || true)"
192+
for guard in "--tmpfs /home/deva/.grok/bin:" "--tmpfs /home/deva/.grok/downloads:"; do
193+
if ! grep -F -- "$guard" <<<"$grok_out" >/dev/null; then
194+
echo "grok oauth: missing update guard $guard" >&2
195+
echo "$grok_out" >&2
196+
exit 1
197+
fi
198+
done
199+
200+
# api-key mode (#403): XAI_API_KEY only, no ~/.grok mount, no guard needed.
201+
apikey_out="$(XAI_API_KEY=test-key run_default grok --auth-with api-key --dry-run || true)"
202+
apikey_grok="$(count_target /home/deva/.grok "$apikey_out")"
203+
if [[ "$apikey_grok" -ne 0 ]]; then
204+
echo "grok api-key: target /home/deva/.grok emitted $apikey_grok times (want 0)" >&2
205+
echo "$apikey_out" >&2
206+
exit 1
207+
fi
208+
if grep -F -- "--tmpfs /home/deva/.grok/bin:" <<<"$apikey_out" >/dev/null; then
209+
echo "grok api-key: update guard emitted without a .grok mount" >&2
210+
echo "$apikey_out" >&2
211+
exit 1
212+
fi
213+
182214
# Explicit --config-home DIR isolates to a single home (no sibling agents).
183215
iso_out="$(run_default claude --config-home "$default_root/xdg/deva/claude" --dry-run || true)"
184216
iso_claude="$(count_target /home/deva/.claude "$iso_out")"

0 commit comments

Comments
 (0)