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

A builder's sandbox runs `bun install` and `bun x tsc --noEmit`: bun gets a writable temp directory #4151

Merged opened this

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

Merged

scout/forge:scout/4136-a-builder-s-sandbox-runs-bun-install-and → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/4136-a-builder-s-sandbox-runs-bun-install-and was merged into main as 2405852.

Review

Sign in to review this change.
2 files changed +30 −0
src/crew/sandbox.ts +17
@@ -60,6 +60,16 @@ export interface SandboxPolicy {
6060 readonly readAllow: readonly string[];
6161 }
6262
63 /**
64 * The one directory under the run's temp directory the sandboxed shell may
65 * write, and the `TMPDIR` it re-exports: `<tmpDir>/claude-<uid>`.
66 */

Nit, not blocking: tempDenied builds the same claude-${process.getuid?.() ?? 0} name a few lines below. A small claudeTmpName() helper shared by both would keep the two from drifting.

67 export function shellTmpDir(policy: SandboxPolicy): string | undefined {
68 return policy.tmpDir === undefined
69 ? undefined
70 : join(policy.tmpDir, `claude-${process.getuid?.() ?? 0}`);
71 }
72
6373 /**
6474 * The run's env for its repository's install cache: the directory made, and
6575 * bun told to use it. A policy with no cache (no repository) changes nothing.
outdated

High: inside the sandbox, policy.tmpDir is read-only. Only <tmpDir>/claude-<uid> is writable (runner.ts:1194-1197), and the shell's own TMPDIR is already that path. Since bun prefers BUN_TMPDIR over TMPDIR, this sends bun to a directory where it gets Read-only file system. Point it at the writable directory, or a directory the policy adds to its write set beside the cache, and prove it with a quoted builder run.

@@ -68,6 +78,13 @@ export function installCacheEnv(
6878 policy: SandboxPolicy,
6979 env: Record<string, string>,
7080 ): void {
81 // Bun reads BUN_TMPDIR before TMPDIR. The run's temp directory is read-only
82 // to the shell; only `shellTmpDir` under it is writable.
83 const tmp = shellTmpDir(policy);
84 if (tmp !== undefined) {
85 mkdirSync(tmp, { recursive: true });
86 env["BUN_TMPDIR"] = tmp;
87 }
7188 if (policy.installCache === undefined) return;
7289 mkdirSync(policy.installCache, { recursive: true });
7390 env["BUN_INSTALL_CACHE_DIR"] = policy.installCache;
test/crew-install-cache.test.ts +13
@@ -70,6 +70,19 @@ describe("the shared install cache", () => {
7070 expect(none).toEqual({});
7171 });
7272
73 test("bun's temp directory is the one the sandboxed shell can write", () => {
74 const p = policy(data, "r1");
75 const env: Record<string, string> = {};
76 installCacheEnv(p, env);
77 const writable = join(p.tmpDir!, `claude-${process.getuid?.() ?? 0}`);
78 // The run's temp directory itself is read-only in the shell; only the
79 // claude-<uid> directory under it is bound writable.
80 expect(env["BUN_TMPDIR"]).toBe(writable);
outdated

This is a tautology: it is always true once line 77 passes. The test needs to assert that BUN_TMPDIR is inside the sandbox's real write set (<tmpDir>/claude-<uid>, the clone, or the cache's allowWrite), and that assertion fails on this change.

81 expect(env["BUN_TMPDIR"]).not.toBe(p.tmpDir!);
82 expect(existsSync(writable)).toBe(true);
83 expect(p.denyRead).not.toContain(p.tmpDir!);
84 });
85
7386 test("removing a repository clears its cache and only its", async () => {
7487 await removeInstallCache(data, "r1");
7588 expect(existsSync(installCacheDir(data, "r1"))).toBe(false);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs