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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10552-a-crew-run-s-sandbox-refuses-reads-outsi → main 3 commits fork 1 thread · 1 unresolved
4 files
changed +213
−0
bun.lock +27
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | "devDependencies": { |
| 8 | 8 | "@anthropic-ai/sandbox-runtime": "^0.0.79", |
| 9 | 9 | "@types/bun": "^1.2.0", |
| 10 | "@vscode/ripgrep": "1.18.0", | |
| 10 | 11 | "axe-core": "^4.14.0", |
| 11 | 12 | "happy-dom": "^21.0.4", |
| 12 | 13 | "typescript": "^7.0.2", |
| @@ -67,6 +68,32 @@ | ||
| 67 | 68 | |
| 68 | 69 | "@typescript/typescript-win32-x64": ["@typescript/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], |
| 69 | 70 | |
| 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 | ||
| 70 | 97 | "axe-core": ["[email protected]", "", {}, "sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ=="], |
| 71 | 98 | |
| 72 | 99 | "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. | ||
| 179 | 179 | |
| 180 | 180 | - `@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. |
| 181 | 181 | - `@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. | |
| 182 | 183 | - `axe-core` — the release check's accessibility rules (`src/release/axe.ts`); they need a rendered page, and there is no zero-dependency equivalent. |
| 183 | 184 | - `happy-dom` — a DOM for tests that run a page's script (`test/changelog.test.ts`) without a browser. |
| 184 | 185 | - `typescript` — the native 7.x checker behind `bun x tsc --noEmit`. |
package.json +1
| @@ -14,6 +14,7 @@ | ||
| 14 | 14 | "devDependencies": { |
| 15 | 15 | "@anthropic-ai/sandbox-runtime": "^0.0.79", |
| 16 | 16 | "@types/bun": "^1.2.0", |
| 17 | "@vscode/ripgrep": "1.18.0", | |
| 17 | 18 | "axe-core": "^4.14.0", |
| 18 | 19 | "happy-dom": "^21.0.4", |
| 19 | 20 | "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 | ); |