Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
2 changes: 1 addition & 1 deletion plugins/grok/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
20 changes: 20 additions & 0 deletions plugins/grok/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
62 changes: 59 additions & 3 deletions plugins/grok/scripts/lib/grok.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 ?? ""));
Expand Down Expand Up @@ -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:
Expand Down
24 changes: 23 additions & 1 deletion tests/companion.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
50 changes: 48 additions & 2 deletions tests/grok.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
REVIEW_TOOL_ALLOWLIST,
TASK_READ_DISALLOWED_TOOLS,
buildHeadlessArgs,
sandboxEnforcerAvailable,
importClaudeSession,
runGrokHeadless
} from "../plugins/grok/scripts/lib/grok.mjs";
Expand Down Expand Up @@ -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"));
Expand Down
Loading