diff --git a/agent-node/src/cli.ts b/agent-node/src/cli.ts index 2d696bf0d..c39bffa71 100644 --- a/agent-node/src/cli.ts +++ b/agent-node/src/cli.ts @@ -3938,7 +3938,7 @@ async function processWithGrokCli( debug(`[grok-cli] cwd=${grokCwd}`); const { execFileSync } = await import("child_process"); - const { runGrokCliTurn, assertGrokCliFeatures, assertGrokCliVersion } = await import("./runtime/grok-build-cli"); + const { runGrokCliTurn, assertGrokCliFeatures, assertGrokCliVersion, assertUnprivilegedUserNsUsable } = await import("./runtime/grok-build-cli"); const { prepareGrokCliHome, assertNoDiscoveredGrokHooks, @@ -3954,6 +3954,11 @@ async function processWithGrokCli( throw new Error("grok-build-cli secure turn supervision currently requires Linux user/PID namespaces"); } const unshareBinary = process.env.UNSHARE_BINARY || "unshare"; + // 上面的 platform 判断是**必要不充分**的:是 Linux 不等于非特权 userns 可用。 + // Ubuntu 24.04+ 默认禁写 uid_map,而这个 runtime 每个 turn 都依赖它。 + // 在这里挡下来,才能给出「换 grok-build-acp」这种可执行建议; + // 否则失败推迟到第一个 turn,以内核层 errno 出现,把人引去查权限。 + assertUnprivilegedUserNsUsable(unshareBinary); const flockBinary = process.env.FLOCK_BINARY || "flock"; const setprivBinary = process.env.SETPRIV_BINARY || "setpriv"; const grokTurnLauncher = { diff --git a/agent-node/src/runtime/grok-build-cli.test.ts b/agent-node/src/runtime/grok-build-cli.test.ts index 3116dbe9e..a814568fb 100644 --- a/agent-node/src/runtime/grok-build-cli.test.ts +++ b/agent-node/src/runtime/grok-build-cli.test.ts @@ -1,8 +1,8 @@ -import { afterEach, describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it, test } from "bun:test"; import { chmodSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; -import { assertGrokCliFeatures, assertGrokCliVersion, buildGrokCliArgs, normalizeGrokCliTools, runGrokCliTurn } from "./grok-build-cli"; +import { assertGrokCliFeatures, assertGrokCliVersion, assertUnprivilegedUserNsUsable, buildGrokCliArgs, normalizeGrokCliTools, runGrokCliTurn } from "./grok-build-cli"; import { buildGrokChildEnv } from "./grok-child-env"; const roots: string[] = []; @@ -359,3 +359,43 @@ describe("runGrokCliTurn", () => { expect(Date.now() - started).toBeLessThan(2_000); }); }); + +describe("assertUnprivilegedUserNsUsable (#grok userns preflight)", () => { + test("passes when the probe succeeds", () => { + let seen: { bin: string; args: string[] } | null = null; + expect(() => assertUnprivilegedUserNsUsable("unshare", (bin, args) => { + seen = { bin, args }; + return { ok: true, stderr: "" }; + })).not.toThrow(); + // 探针必须是**真实那个操作**,不是读 sysctl 之类的代理值。 + expect(seen).toEqual({ bin: "unshare", args: ["--user", "--map-root-user", "/bin/true"] }); + }); + + test("throws with the real stderr and an actionable next step when uid_map is refused", () => { + // 这段 stderr 是 2026-08-13 在 Ubuntu 24.04.3 上实测到的原文。 + const real = "unshare: write failed /proc/self/uid_map: Operation not permitted"; + let msg = ""; + try { + assertUnprivilegedUserNsUsable("unshare", () => ({ ok: false, stderr: real })); + } catch (e: any) { msg = String(e.message); } + expect(msg).toContain("write failed /proc/self/uid_map"); + // 必须指出可执行的出路,而不是只报「失败了」。 + expect(msg).toContain("grok-build-acp"); + // 必须给出自查命令。 + expect(msg).toContain("sysctl kernel.apparmor_restrict_unprivileged_userns"); + // 🔴 不能把「放宽 sysctl」说成推荐做法 —— 那是削弱全机安全边界的运维决策。 + expect(msg).toContain("operator decision"); + }); + + test("still throws when the probe fails with no stderr at all", () => { + // 兜底:拿不到 stderr 也必须 fail-closed,不能因为「没有证据」就放行。 + expect(() => assertUnprivilegedUserNsUsable("unshare", () => ({ ok: false, stderr: "" }))) + .toThrow(/refuses unprivileged user-namespace uid_map writes/); + }); + + test("honours a custom unshare binary path", () => { + let bin = ""; + assertUnprivilegedUserNsUsable("/opt/util-linux/bin/unshare", (b) => { bin = b; return { ok: true, stderr: "" }; }); + expect(bin).toBe("/opt/util-linux/bin/unshare"); + }); +}); diff --git a/agent-node/src/runtime/grok-build-cli.ts b/agent-node/src/runtime/grok-build-cli.ts index 244d61425..4a089e3c2 100644 --- a/agent-node/src/runtime/grok-build-cli.ts +++ b/agent-node/src/runtime/grok-build-cli.ts @@ -103,6 +103,51 @@ export function assertGrokCliVersion(version: string): void { } /** Translate the node profile's Claude-style names to Grok's internal IDs. */ +/** grok-build-cli 每个 turn 都在 `unshare --user --map-root-user` 下跑。 + * 调用方已经挡掉了非 Linux,但**「是 Linux」不等于「非特权 userns 可用」**: + * Ubuntu 24.04+ 默认 `kernel.apparmor_restrict_unprivileged_userns=1`, + * 此时写 /proc/self/uid_map 会被拒。 + * + * 实测(2026-08-13,Ubuntu 24.04.3): + * unshare --user --map-root-user … /bin/true + * → rc=1 "unshare: write failed /proc/self/uid_map: Operation not permitted" + * unshare --user /bin/true + * → rc=0 ← 命名空间本身能建,被拒的**只是 uid_map 那一步** + * + * 没有这道预检,失败会推迟到第一个 turn,并以内核层的 errno 出现 —— + * 读到的人会去查内核/权限,而不是「这个 runtime 在这台机上用不了」。 + * + * 🔴 判据是**真跑一次那个操作**,不是读 sysctl。sysctl 只是一个代理值: + * 发行版、容器、seccomp、LSM 都可能让两者不一致,而真正决定成败的是操作本身。 + * + * `run` 可注入,便于测试;默认用 execFileSync。 */ +export function assertUnprivilegedUserNsUsable( + unshareBinary: string, + run?: (bin: string, args: string[]) => { ok: boolean; stderr: string }, +): void { + const exec = run ?? ((bin: string, args: string[]) => { + try { + require("child_process").execFileSync(bin, args, { stdio: ["ignore", "ignore", "pipe"], timeout: 10_000 }); + return { ok: true, stderr: "" }; + } catch (e: any) { + return { ok: false, stderr: String(e?.stderr ?? e?.message ?? e) }; + } + }); + const probe = exec(unshareBinary, ["--user", "--map-root-user", "/bin/true"]); + if (probe.ok) return; + throw new Error( + "grok-build-cli cannot start: this machine refuses unprivileged user-namespace uid_map writes" + + (probe.stderr.trim() ? ` (${probe.stderr.trim().split("\n")[0]})` : "") + + ".\n" + + " Ubuntu 24.04+ ships kernel.apparmor_restrict_unprivileged_userns=1, which blocks the\n" + + " `unshare --map-root-user` this runtime uses for every turn.\n" + + " Check with: sysctl kernel.apparmor_restrict_unprivileged_userns\n" + + " Preferred fix: use the `grok-build-acp` runtime instead — it does not need user namespaces.\n" + + " (Relaxing the sysctl weakens a host-wide security boundary; that is an operator decision,\n" + + " not something this node should require.)", + ); +} + export function normalizeGrokCliTools(tools: readonly string[]): string[] { const mapped: string[] = []; const unknown: string[] = [];