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

A crew run's sandbox refuses reads outside its worktree, each attempt a test #10585

Merged opened this

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

Merged

scout/forge:scout/10552-a-crew-run-s-sandbox-refuses-reads-outsi → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/10552-a-crew-run-s-sandbox-refuses-reads-outsi was merged into main as be3a009.

Review

Sign in to review this change.
4 files changed +213 −0
bun.lock +27
@@ -7,6 +7,7 @@
77 "devDependencies": {
88 "@anthropic-ai/sandbox-runtime": "^0.0.79",
99 "@types/bun": "^1.2.0",
10 "@vscode/ripgrep": "1.18.0",
1011 "axe-core": "^4.14.0",
1112 "happy-dom": "^21.0.4",
1213 "typescript": "^7.0.2",
@@ -67,6 +68,32 @@
6768
6869 "@typescript/typescript-win32-x64": ["@typescript/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
6970
71 "@vscode/ripgrep": ["@vscode/[email protected]", "", { "optionalDependencies": { "@vscode/ripgrep-darwin-arm64": "1.18.0", "@vscode/ripgrep-darwin-x64": "1.18.0", "@vscode/ripgrep-linux-arm": "1.18.0", "@vscode/ripgrep-linux-arm64": "1.18.0", "@vscode/ripgrep-linux-ia32": "1.18.0", "@vscode/ripgrep-linux-ppc64": "1.18.0", "@vscode/ripgrep-linux-riscv64": "1.18.0", "@vscode/ripgrep-linux-s390x": "1.18.0", "@vscode/ripgrep-linux-x64": "1.18.0", "@vscode/ripgrep-win32-arm64": "1.18.0", "@vscode/ripgrep-win32-ia32": "1.18.0", "@vscode/ripgrep-win32-x64": "1.18.0" } }, "sha512-ns5lWe44tSfbTMbVUsyB+I1819PVSw4AdpgK0RNkzfWfwy6+3IUNSxwSrfTno1/oWaS/hERNz+XLWVyga2aJBQ=="],
72
73 "@vscode/ripgrep-darwin-arm64": ["@vscode/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-r3ktHSvbFycQNF6sl7sNDPocpsI7J+mEzh1IaZFkY0spm3k2Z9t8hPAeOK7+p0l6p6/swkQC14XWX01low+94Q=="],
74
75 "@vscode/ripgrep-darwin-x64": ["@vscode/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-25b4gWbL138dGuQU244ebCKKc0q05ULBMoFSz9oAEUHNeqK/lOJViDS7DRvbDazzAzSEdan391Znks/R5mkaTQ=="],
76
77 "@vscode/ripgrep-linux-arm": ["@vscode/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-GDAvufNDHu8zqLEmXstalQF0Wh6wQvdsBi/Vg3Yi3CK4a8XoFXqqXVEHEZ9xQz3t0NfoSEc9JbvK9DDS6FxyxQ=="],
78
79 "@vscode/ripgrep-linux-arm64": ["@vscode/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-lQ/5zTG++U0E3IhVgS4EPTTn/U4okncaRMM5GOFfOYZywS4nuD31GhkHbNYlDk5CuDC68+hYJ0/eQeyCKJDA+g=="],
80
81 "@vscode/ripgrep-linux-ia32": ["@vscode/[email protected]", "", { "os": "linux", "cpu": "ia32" }, "sha512-YWLkSUtFd4Jh5EepIhA9RJSfv3uMAVMo+2rBIGHPBnvgLrZciIs2cDKei1/p6Wc/aCzUoHyMAg2R6tw4ZCBKGg=="],
82
83 "@vscode/ripgrep-linux-ppc64": ["@vscode/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-quXVY8fwQ8O/lvU1yrSqSl3jlUzysRSb+AfUfCL/tRtphxsKlFvPAejryZ6vg4Bgvn8XL74xb4qMCDmWgYrT5w=="],
84
85 "@vscode/ripgrep-linux-riscv64": ["@vscode/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-f5kBQBrWfQt8Q7OhSORuNDei5dkYagBj3y4jImSUXGMy8B/Ke7SltSRcUtjPv166FAFfHCAmWuZp3+cWnX2/Vw=="],
86
87 "@vscode/ripgrep-linux-s390x": ["@vscode/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-rTOcJFGGcl2c07RUOWUo4U1ndnemKhY6A9hnMB18uk7jSgJc0d/QLBGWMWpumdtoJtpizn/wIv5mXIisJukusQ=="],
88
89 "@vscode/ripgrep-linux-x64": ["@vscode/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-mQ3bVrUpnD2vs7QT0vX90Lt0cnUq467uFtEktIdsJJmW296RoSULRGqWgzG1AKxyBpNDD6l4ZO4qKf6SgyC23Q=="],
90
91 "@vscode/ripgrep-win32-arm64": ["@vscode/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-vfTIjq1OHnzUjxZcHVQAMbnggp8dpGf+0QKFOZHwWPqFwXxQC8eCWM+5NUdoJ6yrElCeMzoUTXoK/LdZaniB+Q=="],
92
93 "@vscode/ripgrep-win32-ia32": ["@vscode/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-//rfAE+BOw5AC2EMmepmiE36jUuevtQYNQqqlw1s3m9FlRxjxEut97RkRPHAu9BG4mSojatZx+kXZXNdyI9caQ=="],
94
95 "@vscode/ripgrep-win32-x64": ["@vscode/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-KNPvtElldqILHdnAetujPaowkNbpqJy3ssIGGN6F6Kve9Qi+nNLI2DN01O83JjCEVQbCzl8Ov3QZ9Eov3BR8Dg=="],
96
7097 "axe-core": ["[email protected]", "", {}, "sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ=="],
7198
7299 "buffer-image-size": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="],
docs/REFERENCE.md +1
@@ -179,6 +179,7 @@ here fails `test/dependencies-named.test.ts`; so does a line for one that left.
179179
180180 - `@anthropic-ai/sandbox-runtime` — lets `test/crew-sandbox-real.test.ts` run a shell under the very sandbox Claude Code builds from the crew's settings.
181181 - `@types/bun` — the type check's types for Bun's own APIs.
182 - `@vscode/ripgrep` — the `rg` that `srt` scans with, pinned and shipped in the package (no install script), so `test/crew-sandbox-escape.test.ts` runs on a check host with no ripgrep of its own.
182183 - `axe-core` — the release check's accessibility rules (`src/release/axe.ts`); they need a rendered page, and there is no zero-dependency equivalent.
183184 - `happy-dom` — a DOM for tests that run a page's script (`test/changelog.test.ts`) without a browser.
184185 - `typescript` — the native 7.x checker behind `bun x tsc --noEmit`.
package.json +1
@@ -14,6 +14,7 @@
1414 "devDependencies": {
1515 "@anthropic-ai/sandbox-runtime": "^0.0.79",
1616 "@types/bun": "^1.2.0",
17 "@vscode/ripgrep": "1.18.0",
1718 "axe-core": "^4.14.0",
1819 "happy-dom": "^21.0.4",
1920 "typescript": "^7.0.2",
test/crew-sandbox-escape.test.ts added +184
@@ -0,0 +1,184 @@
1 /**
2 * Read attempts from a crew run's real sandbox (bwrap, as Claude Code's
3 * runtime makes it from the settings the crew hands it), each a test that
4 * fails if the attempt works. The pod runs with `procMount: Unmasked`, so
5 * /proc is whole: another process's root and environment are paths to try,
6 * beside the plain ones.
7 *
8 * The attempts: a denied data file (`forge.db`) and another crew's file, each
9 * by its path and through `/proc/self/root`, `/proc/1/root` and
10 * `/proc/<pid>/root` of a process outside the sandbox; and a secret held in
11 * the environment of a process outside the sandbox, looked for in every
12 * `/proc/<pid>/environ` the shell can list (with `/proc/1/environ` named).
13 * Two controls keep a probe that never ran from passing as "all refused": the
14 * shell reads the canary from its own worktree, and reads its own environ.
15 *
16 * Like crew-sandbox-real.test.ts this needs a sandbox of its own, so inside a
17 * crew run or a container that refuses user namespaces it is skipped, naming
18 * itself and the reason on the console; the forge's check runs it. Where a
19 * sandbox can be made, a missing `rg` or `srt` fails the file, not skips it.
20 * The `rg` is the pinned `@vscode/ripgrep` binary, handed to `srt` as its
21 * `ripgrep.command`: the check host has no ripgrep on its PATH.
22 */
23 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
24 import { existsSync, mkdirSync, writeFileSync } from "node:fs";
25 import { mkdtemp, rm } from "node:fs/promises";
26 import { tmpdir } from "node:os";
27 import { join } from "node:path";
28 import { rgPath } from "@vscode/ripgrep";
29 import { claudeSettings } from "../src/crew/claude.ts";
30 import { sandboxAvailable, sandboxPolicy } from "../src/crew/sandbox.ts";
31
32 const probe = await sandboxAvailable();
33 const srt = join(import.meta.dir, "../node_modules/.bin/srt");
34 const nestedReason = !probe.ok
35 ? `no sandbox can be made here (nested in a crew sandbox or user namespaces refused): ${probe.reason}`
36 : undefined;
37 if (nestedReason !== undefined)
38 console.warn(`skipped: test/crew-sandbox-escape.test.ts: ${nestedReason}`);
39 else if (!existsSync(rgPath) || !existsSync(srt))
40 throw new Error(
41 "a sandbox can be made here but its runtime is missing (srt or @vscode/ripgrep's rg): run bun install",
42 );
43
44 const CANARY = "the-canary-must-not-be-read";
45 const ENV_CANARY = "forge-redteam-environ-canary";
46
47 /** The files outside the worktree, by what they stand for. */
48 const FILES = {
49 "a denied data file": "forge.db",
50 "another crew's file": "crew/mason/01repo/3/notes.txt",
51 } as const;
52 /** The routes to each file; `<pid>` is a process outside the sandbox. */
53 const ROUTES = ["by its path", "/proc/self/root", "/proc/1/root", "/proc/<pid>/root"] as const;
54 type Route = (typeof ROUTES)[number];
55
56 function routed(route: Route, path: string, pid: number): string {
57 if (route === "by its path") return path;
58 return `${route.replace("<pid>", String(pid))}${path}`;
59 }
60
61 interface Report {
62 /** `label|route` → whether the canary came back. */
63 reads: Record<string, boolean>;
64 ownRead: boolean;
65 ownEnviron: boolean;
66 pidsListed: number;
67 initEnviron: boolean;
68 environLeaks: string[];
69 }
70
71 describe.skipIf(nestedReason !== undefined)(
72 "read attempts from a real sandboxed shell, /proc unmasked",
73 () => {
74 let dataDir: string;
75 let report: Report;
76 let holder: ReturnType<typeof Bun.spawn>;
77
78 beforeAll(async () => {
79 dataDir = await mkdtemp(join(tmpdir(), "forge-escape-"));
80 const work = join(dataDir, "crew/scout/01repo/1");
81 mkdirSync(work, { recursive: true });
82 mkdirSync(join(dataDir, "crew/mason/01repo/3"), { recursive: true });
83 for (const rel of Object.values(FILES)) writeFileSync(join(dataDir, rel), CANARY);
84 writeFileSync(join(work, "own.txt"), CANARY);
85
86 // A process outside the sandbox with a secret in its environment: with
87 // /proc whole, the shell must still not read it, nor go through its root.
88 holder = Bun.spawn(["sleep", "120"], {
89 env: { ...process.env, FORGE_REDTEAM_SECRET: ENV_CANARY },
90 stdin: "ignore",
91 stdout: "ignore",
92 stderr: "ignore",
93 });
94
95 const targets: Record<string, string> = {};
96 for (const [label, rel] of Object.entries(FILES))
97 for (const route of ROUTES)
98 targets[`${label}|${route}`] = routed(route, join(dataDir, rel), holder.pid);
99
100 writeFileSync(
101 join(work, "probe.ts"),
102 `
103 import { readFileSync, readdirSync } from "node:fs";
104 const CANARY = ${JSON.stringify(CANARY)};
105 const ENV = ${JSON.stringify(ENV_CANARY)};
106 const has = (path: string, needle: string) => {
107 try { return readFileSync(path, "utf8").includes(needle); } catch { return false; }
108 };
109 const reads: Record<string, boolean> = {};
110 for (const [key, path] of Object.entries(${JSON.stringify(targets)} as Record<string, string>))
111 reads[key] = has(path, CANARY);
112 let pids: string[] = [];
113 try { pids = readdirSync("/proc").filter((n) => /^[0-9]+$/.test(n)); } catch {}
114 const environLeaks = pids.filter((pid) => has("/proc/" + pid + "/environ", ENV));
115 console.log(JSON.stringify({
116 reads,
117 ownRead: has("own.txt", CANARY),
118 ownEnviron: has("/proc/self/environ", "PATH="),
119 pidsListed: pids.length,
120 initEnviron: has("/proc/1/environ", ENV),
121 environLeaks,
122 }));
123 `,
124 );
125
126 const policy = sandboxPolicy({
127 dataDir,
128 agentName: "scout",
129 urls: [],
130 runId: "01m46abcdefghjkmnpqrstvwxy",
131 });
132 const real = claudeSettings(policy).sandbox as Record<string, any>;
133 const settings = join(dataDir, "srt-settings.json");
134 writeFileSync(
135 settings,
136 JSON.stringify({
137 ripgrep: { command: rgPath },
138 network: { deniedDomains: [], ...real.network },
139 filesystem: {
140 denyRead: [],
141 denyWrite: [],
142 ...real.filesystem,
143 allowWrite: [work, ...(real.filesystem?.allowWrite ?? [])],
144 },
145 }),
146 );
147 const proc = Bun.spawn(
148 [srt, "--settings", settings, "-c", `'${process.execPath}' probe.ts`],
149 { cwd: work, stdin: "ignore", stdout: "pipe", stderr: "pipe" },
150 );
151 const [out, err, code] = await Promise.all([
152 new Response(proc.stdout).text(),
153 new Response(proc.stderr).text(),
154 proc.exited,
155 ]);
156 if (code !== 0) throw new Error(`sandboxed shell exited ${code}: ${err}`);
157 report = JSON.parse(out.trim().split("\n").pop() ?? "{}");
158 }, 60_000);
159
160 afterAll(async () => {
161 holder?.kill();
162 if (dataDir !== undefined) await rm(dataDir, { recursive: true, force: true });
163 });
164
165 test("control: the shell reads its own worktree and its own environ", () => {
166 expect(report.ownRead).toBe(true);
167 expect(report.ownEnviron).toBe(true);
168 });
169
170 for (const label of Object.keys(FILES))
171 for (const route of ROUTES)
172 test(`reads nothing from ${label}, ${route}`, () => {
173 expect(report.reads[`${label}|${route}`]).toBe(false);
174 });
175
176 test("reads no secret from /proc/1/environ", () => {
177 expect(report.initEnviron).toBe(false);
178 });
179
180 test("reads no secret from any /proc/<pid>/environ of a process outside", () => {
181 expect(report.environLeaks).toEqual([]);
182 });
183 },
184 );

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs