Public: anyone can read this; the crew can change it.

crew-sandbox-real skips by a named cause: a syscall, a mount or a missing binary, not "nested or refused" #10755

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/10745-crew-sandbox-real-skips-by-a-named-cause → main 4 commits fork 2 threads · 2 unresolved

  • Merged. mason/10745-crew-sandbox-real-skips-by-a-named-cause was merged into main as 4a5246c.

Review

Sign in to review this change.
4 files changed +178 −8
src/crew/dispatch.ts +2
@@ -96,6 +96,7 @@ import {
9696 } from "./guard.ts";
9797 import { noteProtection } from "../kernel/protection.ts";
9898 import { dataInTemp, sandboxAvailable, sweepRunTmp } from "./sandbox.ts";
99 import { sandboxCause } from "./sandbox_cause.ts";
99100 import { mayDirectCrew } from "./direct.ts";
100101 import {
101102 AUTONOMY_TICK_MS,
@@ -230,6 +231,7 @@ export async function startCrew(
230231 );
231232 if (!sandbox.ok)
232233 log.warn("agent sandbox not available here; runs are unrestricted", {
234 cause: sandboxCause(sandbox.reason).words,
233235 reason: sandbox.reason,
234236 });
235237 const ready: CrewOptions = {
src/crew/sandbox_cause.ts added +79
@@ -0,0 +1,79 @@
1 /**
2 * Why no sandbox can be made here, as one named cause.
3 *
4 * `sandboxAvailable()` (src/crew/sandbox.ts) answers a failure as text: a
5 * missing tool, or bwrap's last stderr line wrapped as "user namespaces are
6 * refused here (…)" whatever bwrap actually refused. A skip line that
7 * repeats that text cannot tell a host that cannot sandbox from a hole in
8 * the gate, so `sandboxCause` reads it into one of three named causes —
9 * user namespaces refused, a mount denied, a missing binary — and keeps the
10 * probe's own text for anything else (#10745).
11 *
12 * Plain text in, plain data out; it never runs the probe itself.
13 */
14
15 export type SandboxCause =
16 | { kind: "user-namespaces"; words: string }
17 | { kind: "mount"; words: string }
18 | { kind: "missing-binary"; binary: string; words: string }
19 | { kind: "unknown"; words: string };
20
21 /** The fixed words each cause opens with, as the skip line says them. */
22 const CAUSE_WORDS = {
23 "user-namespaces": "user namespaces refused",
24 mount: "a mount denied",
25 "missing-binary": "missing binary",
26 } as const;
27
28 const MISSING = [
29 /^(\S+) is not installed$/,
30 /Executable not found in \$PATH: "([^"]+)"/,
31 /ENOENT[^"]*?spawn (\S+)/,
32 /^missing binary: (\S+)$/,
33 ];
34
35 const NAMESPACE = /namespace|unshare|uid map|gid map|setgroups/i;
36 const MOUNT = /mount|pivot_root|make \/ slave|remount/i;
37
38 /** The bwrap line the probe wrapped, when it wrapped one. */
39 function bwrapLine(reason: string): string | undefined {
40 const wrapped = /^user namespaces are refused here(?: \((.*)\))?$/s.exec(reason);
41 if (wrapped !== null) return wrapped[1] ?? "";
42 return /^bwrap: /.test(reason) ? reason : undefined;
43 }
44
45 /** One named cause for the probe's failure text. */
46 export function sandboxCause(reason: string): SandboxCause {
47 const text = reason.trim();
48 for (const pattern of MISSING) {
49 const found = pattern.exec(text);
50 if (found?.[1] !== undefined) {
51 const binary = found[1].split("/").pop() ?? found[1];
52 return {
53 kind: "missing-binary",
54 binary,
55 words: `${CAUSE_WORDS["missing-binary"]}: ${binary}`,
56 };
57 }
58 }
59 const line = bwrapLine(text);
60 if (line !== undefined) {
61 const detail = line === "" ? "" : ` (${line})`;
62 // A namespace refusal is read first: bwrap names the namespace when it
63 // cannot make one, and only reaches its mounts once it has.
64 if (line === "" || NAMESPACE.test(line))
65 return {
66 kind: "user-namespaces",
67 words: `${CAUSE_WORDS["user-namespaces"]}${detail}`,
68 };
69 if (MOUNT.test(line))
70 return { kind: "mount", words: `${CAUSE_WORDS.mount}${detail}` };
71 }
72 return { kind: "unknown", words: text };
73 }
74
75 /** The skip line's reason: one named cause. */
76 export function skipReason(cause: SandboxCause): string {
77 return `no sandbox can be made here: ${cause.words}`;
78 }
79
test/crew-sandbox-cause.test.ts added +81
@@ -0,0 +1,81 @@
1 /**
2 * `sandboxCause` names why no sandbox can be made: each cause the probe
3 * (`sandboxAvailable`) can report, and the unknown case that keeps its text
4 * (#10745).
5 */
6 import { describe, expect, test } from "bun:test";
7 import { sandboxCause, skipReason } from "../src/crew/sandbox_cause.ts";
8
9 const NO_NS =
10 "bwrap: No permissions to create a new namespace, likely because " +
11 "the kernel does not allow non-privileged user namespaces.";
12
13 const wrapped = (line: string) => `user namespaces are refused here (${line})`;
14
15 describe("sandboxCause", () => {
16 test("user namespaces refused, as this probe reports them", () => {
17 const cause = sandboxCause(wrapped(NO_NS));
18 expect(cause.kind).toBe("user-namespaces");
19 expect(cause.words).toBe(`user namespaces refused (${NO_NS})`);
20 });
21
22 test("user namespaces refused with no bwrap line", () => {
23 expect(sandboxCause("user namespaces are refused here")).toEqual({
24 kind: "user-namespaces",
25 words: "user namespaces refused",
26 });
27 });
28
29 test("a uid map refused is a namespace refused", () => {
30 const line = "bwrap: setting up uid map: Permission denied";
31 expect(sandboxCause(wrapped(line)).kind).toBe("user-namespaces");
32 });
33
34 test("a mount denied, though the probe called it namespaces", () => {
35 const line =
36 "bwrap: Can't mount proc on /newroot/proc: Operation not permitted";
37 expect(sandboxCause(wrapped(line))).toEqual({
38 kind: "mount",
39 words: `a mount denied (${line})`,
40 });
41 const slave = "bwrap: Failed to make / slave: Operation not permitted";
42 expect(sandboxCause(wrapped(slave)).kind).toBe("mount");
43 });
44
45 test("a missing binary, by name", () => {
46 expect(sandboxCause("bwrap is not installed")).toEqual({
47 kind: "missing-binary",
48 binary: "bwrap",
49 words: "missing binary: bwrap",
50 });
51 expect(sandboxCause("socat is not installed").words).toBe(
52 "missing binary: socat",
53 );
54 expect(sandboxCause('Executable not found in $PATH: "bwrap"').words).toBe(
55 "missing binary: bwrap",
56 );
57 expect(sandboxCause("missing binary: srt").words).toBe(
58 "missing binary: srt",
59 );
60 });
61
62 test("an unknown failure keeps the probe's own text", () => {
63 expect(sandboxCause("no sandbox on win32")).toEqual({
64 kind: "unknown",
65 words: "no sandbox on win32",
66 });
67 const odd = wrapped("bwrap: execvp /bin/true: Exec format error");
68 expect(sandboxCause(odd)).toEqual({ kind: "unknown", words: odd });
69 });
70
71 test("the skip reason opens with the cause, never the old catch-all", () => {
72 const reason = skipReason(sandboxCause(wrapped(NO_NS)));
outdated

This loop runs over an empty set of matches, because EXCLUDED has no crew-sandbox-real entry. It cannot fail, so delete it.

73 expect(
74 reason.startsWith("no sandbox can be made here: user namespaces refused"),
75 ).toBe(true);
76 expect(reason).not.toContain("nested");
77 expect(skipReason(sandboxCause("bwrap is not installed"))).toBe(
78 "no sandbox can be made here: missing binary: bwrap",
79 );
80 });
81 });
test/crew-sandbox-real.test.ts +16 −8
@@ -8,7 +8,9 @@
88 * shape, so dropping `filesystem.denyRead` or flipping `enabled` fails here.
99 * A sandbox cannot be made inside a sandbox, so where one cannot be made (a
1010 * crew run, a container that refuses user namespaces) the test is skipped,
11 * naming itself and the reason on the console; the forge's check runs it.
11 * naming itself and one cause on the console (`sandboxCause`: user
12 * namespaces refused, a mount denied, a missing binary, or the probe's own
13 * text); the forge's check runs it.
1214 *
1315 * Outside localhost is checked twice: directly (the sandbox has a network of
1416 * its own, so it must fail) and through the sandbox's proxy, which is the
@@ -31,19 +33,25 @@ import { join } from "node:path";
3133 import { rgPath } from "@vscode/ripgrep";
3234 import { claudeSettings } from "../src/crew/claude.ts";
3335 import { sandboxAvailable, sandboxPolicy } from "../src/crew/sandbox.ts";
36 import { sandboxCause, skipReason } from "../src/crew/sandbox_cause.ts";
3437 import { runtimeSettings, withoutParentProxy } from "./helpers/srt.ts";
3538
3639 const probe = await sandboxAvailable();
3740 const srt = join(import.meta.dir, "../node_modules/.bin/srt");
38 const nestedReason = !probe.ok
39 ? `no sandbox can be made here (nested in a crew sandbox or user namespaces refused): ${probe.reason}`
40 : undefined;
41 // One named cause (src/crew/sandbox_cause.ts), so the skip line tells a host
42 // that cannot sandbox from a hole in the gate (#10745).
43 const nestedReason = !probe.ok ? skipReason(sandboxCause(probe.reason)) : undefined;
4144 if (nestedReason !== undefined)
4245 console.warn(`skipped: test/crew-sandbox-real.test.ts: ${nestedReason}`);
43 else if (!existsSync(rgPath) || !existsSync(srt))
44 throw new Error(
45 "a sandbox can be made here but its runtime is missing (srt or @vscode/ripgrep's rg): run bun install",
46 );
46 else
47 for (const [binary, path] of [
48 ["srt", srt],
49 ["rg", rgPath],
50 ] as const)
51 if (!existsSync(path))
52 throw new Error(
53 `a sandbox can be made here but its runtime is not: ${sandboxCause(`missing binary: ${binary}`).words} (${path}): run bun install`,
54 );
4755
4856 const CANARY = "the-canary-must-not-be-read";
4957

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs