From f8665a96df0445dcf345dfe4b1d203e334f71930 Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Tue, 25 Aug 2026 00:11:11 -0700 Subject: [PATCH] fix: probe for the sandbox enforcer instead of assuming one (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit grok 1.0.3's Linux enforcer is bubblewrap, which plain Debian/Ubuntu and WSL images do not ship. It does not warn and continue — it refuses to start with exit 1 and empty stdout, so from the plugin's side every sandboxed invocation just failed, several layers from the cause. @roy7 reported it with a live repro. The two modes get different answers on purpose. A read-leaning rescue's primary guard is the tool deny list, which needs no kernel, so it drops the flag and runs with one belt instead of two. `--write` refuses: there the workspace sandbox is the only thing bounding edits, and degrading it silently would hand an agent the whole disk. The error carries `apt install -y bubblewrap`. Also stops re-asserting --sandbox on --resume. Passing a profile that differs from the session's saved one is a hard error and omitting it is always accepted, so the safe rule is to not send it again. The README claimed the opposite behaviour — silent degradation under Landlock, verified empirically against an older Grok. Corrected, with the asymmetry and the override documented. Tests cover both machines, which is the part that was missing: the fake grok cannot tell the suite whether this box can enforce a sandbox, so GROK_COMPANION_SANDBOX stands in for the machine and every sandbox assertion now runs twice. --- .claude-plugin/marketplace.json | 4 +- CHANGELOG.md | 20 ++++++++ README.md | 17 ++++++- llms.txt | 2 +- plugins/grok/.claude-plugin/plugin.json | 2 +- plugins/grok/CHANGELOG.md | 20 ++++++++ plugins/grok/scripts/lib/grok.mjs | 62 +++++++++++++++++++++++-- tests/companion.test.mjs | 24 +++++++++- tests/grok.test.mjs | 50 +++++++++++++++++++- 9 files changed, 189 insertions(+), 12 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index fd04b73..8ea36c3 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,13 +5,13 @@ }, "metadata": { "description": "Grok plugins to use in Claude Code for delegation and code review.", - "version": "0.1.0" + "version": "0.1.1" }, "plugins": [ { "name": "grok", "description": "Use xAI's Grok CLI from Claude Code for code reviews and task delegation.", - "version": "0.1.0", + "version": "0.1.1", "author": { "name": "thevibeworks" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 359dda0..65f8a44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 0.1.1 - 2026-08-25 + +Fixed a start-up failure on Linux machines without a sandbox enforcer. + +- `--sandbox` is no longer sent unconditionally. Grok's Linux enforcer is + bubblewrap, which plain Debian/Ubuntu and WSL images do not ship, and grok + 1.0.3 **refuses to start** when it is missing rather than warning and + continuing — so every sandboxed invocation failed with exit 1 and empty + stdout. Reported with a live repro by @roy7 in #1. +- A read-leaning rescue now drops the flag and keeps its tool deny list, which + is the guard that never needed the kernel. `--write` refuses instead, because + there the workspace sandbox is the only thing bounding edits; the error names + `apt install -y bubblewrap`. +- `GROK_COMPANION_SANDBOX=on|off` overrides the probe. +- `--sandbox` is no longer re-sent on `--resume`. A profile that differs from + the session's saved one is a hard error, and omitting it is always accepted. +- Security Model in the README documented the opposite behaviour (silent + degradation under Landlock), which was true of older Grok releases and is not + true of 1.0.3. + ## 0.1.0 - 2026-07-12 Initial release. diff --git a/README.md b/README.md index 5ac7f54..186bbad 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ Hands a task to Grok through the `grok:grok-rescue` subagent. /grok:rescue --background investigate the regression ``` -- Default rescues run read-leaning: Grok keeps its shell for running tests and git, but loses the direct file-edit tools and runs under Grok's `read-only` OS sandbox where the kernel supports it. +- Default rescues run read-leaning: Grok keeps its shell for running tests and git, but loses the direct file-edit tools and runs under Grok's `read-only` OS sandbox where the kernel can enforce one (see [Security Model](#security-model) — on Linux that means `bwrap` is installed). - `--write` switches to a write-capable run under Grok's `workspace` sandbox. - `--resume` continues the latest rescue session for this repo; `--fresh` forces a new one. Without either flag the plugin offers to continue when a resumable session exists. - Model and reasoning effort default to Grok's own choices; pass `--model` / `--effort` to override. @@ -174,7 +174,20 @@ Three different enforcement layers, applied per command: | `rescue` (default) | Grok `read-only` OS sandbox + file-edit tools removed. Shell stays available for tests/git. | | `rescue --write` | Grok `workspace` sandbox, auto-approved tools. | -Caveat worth knowing: Grok's OS sandbox uses Landlock (Linux) / Seatbelt (macOS). In containers without Landlock the sandbox **silently degrades** — we verified this empirically — which means a default rescue's shell could still write files there. The review commands do not have this problem; their allowlist removes the shell entirely. If you need hard guarantees for rescues, run them in a disposable environment. +Caveat worth knowing: Grok's OS sandbox needs a kernel enforcer, and on Linux that is now **bubblewrap** (`bwrap`), which plain Debian/Ubuntu and WSL images do not ship. Older Grok releases used Landlock and degraded silently when it was unavailable; **grok 1.0.3 does the opposite and refuses to start** rather than run with an unenforced deny list — reported with a live repro in [#1](https://github.com/thevibeworks/grok-plugin-cc/issues/1). + +So the plugin probes before it asks: + +| Machine | `rescue` (read-leaning) | `rescue --write` | +| --- | --- | --- | +| enforcer present | `--sandbox read-only` + deny list | `--sandbox workspace` | +| enforcer missing | runs, **deny list only** — the flag is dropped because sending it would just fail | **refuses**, and names the fix | + +The asymmetry is deliberate. A read-leaning rescue's primary guard is the tool deny list, which needs no kernel, so losing the sandbox costs one belt out of two. For `--write` the sandbox is the *only* thing keeping edits inside the workspace, and degrading that silently would hand an agent the whole disk — so it fails with `apt install -y bubblewrap` in the message. + +Override the probe with `GROK_COMPANION_SANDBOX=on|off` if your distro enforces through something we do not recognise, or if you have decided to accept the risk. + +The review commands are unaffected either way: their allowlist removes the shell entirely, so it holds everywhere, containers included. If you need hard guarantees for rescues, install the enforcer or run them in a disposable environment. ## When to use · When to skip diff --git a/llms.txt b/llms.txt index d910fd2..2fecde3 100644 --- a/llms.txt +++ b/llms.txt @@ -24,7 +24,7 @@ Distribution: a Claude Code plugin marketplace at the repo root (`.claude-plugin ## Facts agents need - The plugin shells out to the user's own `grok` binary in headless mode (`grok -p` with `--output-format json|streaming-json`, `--json-schema` for reviews); same config, credentials, and usage limits as their direct Grok use. -- Review enforcement is the tool allowlist, which holds in containers; Grok's OS sandbox (Landlock/Seatbelt) silently degrades in containers without Landlock, which only affects default rescues. +- Review enforcement is the tool allowlist, which holds in containers. Grok's OS sandbox needs a kernel enforcer (bubblewrap on Linux, Seatbelt on macOS); grok 1.0.3 refuses to start when it is missing rather than degrading, so the plugin probes: a read-leaning rescue drops `--sandbox` and keeps its tool deny list, and `--write` refuses with the install command. Override with `GROK_COMPANION_SANDBOX=on|off`. - Job state lives under Claude's plugin data directory, scoped per workspace; a SessionEnd hook cancels jobs the ending Claude session started. - License Apache-2.0; architecture derived from openai/codex-plugin-cc (see NOTICE). diff --git a/plugins/grok/.claude-plugin/plugin.json b/plugins/grok/.claude-plugin/plugin.json index 490657b..f6e74dd 100644 --- a/plugins/grok/.claude-plugin/plugin.json +++ b/plugins/grok/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "grok", - "version": "0.1.0", + "version": "0.1.1", "description": "Use xAI's Grok CLI from Claude Code for code reviews and task delegation.", "author": { "name": "thevibeworks" diff --git a/plugins/grok/CHANGELOG.md b/plugins/grok/CHANGELOG.md index 359dda0..65f8a44 100644 --- a/plugins/grok/CHANGELOG.md +++ b/plugins/grok/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 0.1.1 - 2026-08-25 + +Fixed a start-up failure on Linux machines without a sandbox enforcer. + +- `--sandbox` is no longer sent unconditionally. Grok's Linux enforcer is + bubblewrap, which plain Debian/Ubuntu and WSL images do not ship, and grok + 1.0.3 **refuses to start** when it is missing rather than warning and + continuing — so every sandboxed invocation failed with exit 1 and empty + stdout. Reported with a live repro by @roy7 in #1. +- A read-leaning rescue now drops the flag and keeps its tool deny list, which + is the guard that never needed the kernel. `--write` refuses instead, because + there the workspace sandbox is the only thing bounding edits; the error names + `apt install -y bubblewrap`. +- `GROK_COMPANION_SANDBOX=on|off` overrides the probe. +- `--sandbox` is no longer re-sent on `--resume`. A profile that differs from + the session's saved one is a hard error, and omitting it is always accepted. +- Security Model in the README documented the opposite behaviour (silent + degradation under Landlock), which was true of older Grok releases and is not + true of 1.0.3. + ## 0.1.0 - 2026-07-12 Initial release. diff --git a/plugins/grok/scripts/lib/grok.mjs b/plugins/grok/scripts/lib/grok.mjs index c044af9..2cb4f6f 100644 --- a/plugins/grok/scripts/lib/grok.mjs +++ b/plugins/grok/scripts/lib/grok.mjs @@ -20,6 +20,43 @@ export const TASK_READ_DISALLOWED_TOOLS = "search_replace,write"; const MAX_STDERR_BYTES = 64 * 1024; const DEFAULT_MAX_TURNS = 50; +// grok enforces `--sandbox` with a per-platform kernel mechanism, and on Linux +// that mechanism is bubblewrap. When `bwrap` is missing grok does not warn and +// continue -- it refuses to start: +// +// error: this sandbox could not enforce its deny list on Linux: bwrap exec +// failed: No such file or directory (os error 2). Install bubblewrap with +// `apt install -y bubblewrap`. Refusing to start with denied paths +// unprotected. +// +// Exit 1, empty stdout. Plain Debian/Ubuntu and WSL images do not ship it, so +// every sandboxed invocation failed on those machines with the cause several +// layers from the symptom. Reported by @roy7 in #1 with a live repro. +// +// macOS (Seatbelt) and Windows need no extra package, so only Linux is probed. +const LINUX_SANDBOX_ENFORCER = "bwrap"; + +// GROK_COMPANION_SANDBOX overrides the probe: "off" for a machine that cannot +// install the enforcer, "on" for a distro that enforces through something the +// probe does not recognise. Unset means probe. +export function sandboxEnforcerAvailable(platform = process.platform) { + const override = String(process.env.GROK_COMPANION_SANDBOX ?? "").toLowerCase(); + if (["0", "off", "false", "none"].includes(override)) { + return false; + } + if (["1", "on", "true", "force"].includes(override)) { + return true; + } + if (platform !== "linux") { + return true; + } + return binaryAvailable(LINUX_SANDBOX_ENFORCER, ["--version"]).available; +} + +export const SANDBOX_MISSING_HINT = + `the Linux sandbox enforcer (${LINUX_SANDBOX_ENFORCER}) is not installed; ` + + "install it with `apt install -y bubblewrap` (or the equivalent for your distro)"; + export function getGrokAvailability(cwd) { const status = binaryAvailable(GROK_BIN, ["--version"], { cwd }); return { @@ -65,8 +102,12 @@ function truncate(text, limit) { export function buildHeadlessArgs(options = {}) { const args = []; + const resuming = Boolean(options.resumeSessionId); + // Injectable so the unit suite can exercise both machines. Callers leave it + // unset and get the real probe. + const sandboxed = options.sandboxEnforced ?? sandboxEnforcerAvailable(); - if (options.resumeSessionId) { + if (resuming) { args.push("--resume", String(options.resumeSessionId)); } args.push("-p", String(options.prompt ?? "")); @@ -98,12 +139,27 @@ export function buildHeadlessArgs(options = {}) { args.push("--always-approve"); break; case "task-read": - args.push("--sandbox", "read-only"); + // The deny list is the primary guard here and does not need the kernel, + // so a machine without the enforcer still runs -- with one belt instead + // of two. Dropping the flag is what keeps grok from refusing to start. + if (sandboxed && !resuming) { + args.push("--sandbox", "read-only"); + } args.push("--disallowed-tools", TASK_READ_DISALLOWED_TOOLS); args.push("--always-approve"); break; case "task-write": - args.push("--sandbox", "workspace"); + // Here the sandbox is the only thing keeping writes inside the + // workspace. Degrading silently would hand an agent the whole disk, so + // this fails with the fix in the message instead. + if (!sandboxed) { + throw new Error( + `grok task-write needs a workspace sandbox and ${SANDBOX_MISSING_HINT}.` + ); + } + if (!resuming) { + args.push("--sandbox", "workspace"); + } args.push("--always-approve"); break; default: diff --git a/tests/companion.test.mjs b/tests/companion.test.mjs index 7c971b5..961961e 100644 --- a/tests/companion.test.mjs +++ b/tests/companion.test.mjs @@ -83,13 +83,35 @@ test("task runs foreground, records grok session, and resume-last reuses it", () assert.equal(resumed[resumed.indexOf("--resume") + 1], "fake-session-stream"); }); +// The fake grok cannot tell us whether this machine can enforce a sandbox, so +// the env override stands in for the machine. Both shapes are covered on +// purpose: CI images and plain Debian/Ubuntu/WSL do not ship bubblewrap, and a +// suite that only ever saw the sandboxed machine is why #1 shipped. test("task --write switches to the workspace sandbox", () => { - runJson(["task", "--write", "fix", "the", "bug"]); + runJson(["task", "--write", "fix", "the", "bug"], { GROK_COMPANION_SANDBOX: "on" }); const calls = fake.readCalls(); const writeCall = [...calls].reverse().find((call) => call.includes("--sandbox")); assert.equal(writeCall[writeCall.indexOf("--sandbox") + 1], "workspace"); }); +test("task --write refuses on a machine with no sandbox enforcer", () => { + const result = runCompanion(["task", "--write", "fix", "the", "bug", "--json"], { + cwd: repoDir, + env: { ...baseEnv, GROK_COMPANION_SANDBOX: "off" } + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /workspace sandbox/); + assert.match(result.stderr, /bubblewrap/, "the message has to carry the fix"); +}); + +test("task read-only still runs with no sandbox enforcer", () => { + const payload = runJson(["task", "look", "around"], { GROK_COMPANION_SANDBOX: "off" }); + assert.ok(payload); + const call = [...fake.readCalls()].reverse().find((c) => c.includes("--disallowed-tools")); + assert.ok(call, "the deny list is still the guard"); + assert.ok(!call.includes("--sandbox"), "an unenforceable sandbox flag must not be sent"); +}); + test("background task queues a job that the worker completes", async () => { const payload = runJson(["task", "--background", "long", "running", "investigation"]); assert.equal(payload.status, "queued"); diff --git a/tests/grok.test.mjs b/tests/grok.test.mjs index 3cf0c67..630f7f7 100644 --- a/tests/grok.test.mjs +++ b/tests/grok.test.mjs @@ -6,6 +6,7 @@ import { REVIEW_TOOL_ALLOWLIST, TASK_READ_DISALLOWED_TOOLS, buildHeadlessArgs, + sandboxEnforcerAvailable, importClaudeSession, runGrokHeadless } from "../plugins/grok/scripts/lib/grok.mjs"; @@ -47,19 +48,64 @@ test("review mode enforces the read-only tool allowlist", () => { }); test("task-read mode combines sandbox and write-tool denylist", () => { - const args = buildHeadlessArgs({ prompt: "investigate", mode: "task-read" }); + const args = buildHeadlessArgs({ prompt: "investigate", mode: "task-read", sandboxEnforced: true }); assert.equal(pairValue(args, "--sandbox"), "read-only"); assert.equal(pairValue(args, "--disallowed-tools"), TASK_READ_DISALLOWED_TOOLS); assert.equal(pairValue(args, "--output-format"), "streaming-json"); }); test("task-write mode uses the workspace sandbox", () => { - const args = buildHeadlessArgs({ prompt: "fix it", mode: "task-write", model: "grok-4", effort: "high" }); + const args = buildHeadlessArgs({ + prompt: "fix it", mode: "task-write", model: "grok-4", effort: "high", sandboxEnforced: true + }); assert.equal(pairValue(args, "--sandbox"), "workspace"); assert.equal(pairValue(args, "-m"), "grok-4"); assert.equal(pairValue(args, "--effort"), "high"); }); +// Without a kernel enforcer grok refuses to start rather than warning, so a +// flag we cannot back up is a flag we must not send. #1. +test("task-read drops the sandbox when the enforcer is missing, and keeps the deny list", () => { + const args = buildHeadlessArgs({ prompt: "investigate", mode: "task-read", sandboxEnforced: false }); + assert.ok(!args.includes("--sandbox"), "an unenforceable sandbox flag must not be sent"); + assert.equal(pairValue(args, "--disallowed-tools"), TASK_READ_DISALLOWED_TOOLS, + "the tool-level guard is what still stops writes"); +}); + +test("task-write refuses rather than running unsandboxed", () => { + assert.throws( + () => buildHeadlessArgs({ prompt: "fix it", mode: "task-write", sandboxEnforced: false }), + /needs a workspace sandbox.*bubblewrap/s, + "the workspace sandbox is the only guard on writes; degrading it silently is worse than failing" + ); +}); + +// Passing a profile that differs from the session's saved one is a hard error, +// and omitting it on resume is always accepted. So never re-assert it. +test("resuming does not re-assert a sandbox profile", () => { + for (const mode of ["task-read", "task-write"]) { + const args = buildHeadlessArgs({ + prompt: "continue", mode, resumeSessionId: "abc123", sandboxEnforced: true + }); + assert.ok(!args.includes("--sandbox"), `${mode} must not re-send --sandbox on resume`); + } +}); + +test("the enforcer probe honours GROK_COMPANION_SANDBOX", () => { + const saved = process.env.GROK_COMPANION_SANDBOX; + try { + process.env.GROK_COMPANION_SANDBOX = "off"; + assert.equal(sandboxEnforcerAvailable("darwin"), false); + process.env.GROK_COMPANION_SANDBOX = "on"; + assert.equal(sandboxEnforcerAvailable("linux"), true); + delete process.env.GROK_COMPANION_SANDBOX; + assert.equal(sandboxEnforcerAvailable("darwin"), true, "only Linux needs a package"); + } finally { + if (saved === undefined) delete process.env.GROK_COMPANION_SANDBOX; + else process.env.GROK_COMPANION_SANDBOX = saved; + } +}); + test("resume flag precedes the prompt", () => { const args = buildHeadlessArgs({ prompt: "continue", mode: "task-read", resumeSessionId: "abc123" }); assert.ok(args.indexOf("--resume") < args.indexOf("-p"));