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

A sandboxed agent can run this repository's tests: unix sockets and loopback ports in its own temp directory, with the boundary kept #1108

Merged opened this in answer to

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

Merged

scout/forge:scout/1107-a-sandboxed-agent-can-run-this-repositor → main 3 commits fork 9 threads · 9 unresolved

  • Merged. scout/1107-a-sandboxed-agent-can-run-this-repositor was merged into main as 9aca0ed.

Review

Sign in to review this change.
15 files changed +369 −18
docs/CREW.md added +35
@@ -0,0 +1,35 @@
1 # The crew's shell
2
3 An agent's shell runs inside Claude Code's sandbox (`src/crew/sandbox.ts` is
4 the policy, `src/crew/claude.ts` the settings): the network is the forge and
5 the package registry, writes stay in the clone and the run's own `tmpDir`,
6 and the machine's temp directory, the forge's data and the operator's
7 credentials are unreadable. No command runs outside it on request.
8
9 ## Running this repository's tests inside it
10
11 On Linux the sandbox refuses the bind of a unix socket, and the forge's
12 internal listener (the git hooks' door) is one. Claude Code can only allow
13 unix sockets all or none, which would open every socket the machine has, so
14 the boundary is kept instead: a run's environment carries
15 `FORGE_TEST_SOCKET=port`, and the forge listens on a loopback port
16 (`tcp:127.0.0.1:<port>`, `src/git/hook-address.ts`) where it would use a
17 socket. Loopback ports were already allowed (`allowLocalBinding`), and
18 `localhost` outside the sandbox stays unreachable.
19
20 What an agent can run: the type check and `bun test` over `test/`, with no
outdated

Not accurate: the two hardening suites are not the only socket-bound ones. The egress proxy (startEgress) and the env-data/app sockets bind unix sockets too, and their suites fail under FORGE_TEST_SOCKET=port.

21 failures. A cell reaches its egress proxy through a unix socket, which cannot
22 be put on a port, so in port mode `cellsAvailable()` answers no
23 (`FORGE_TEST_SOCKET=port: no unix sockets`) and deploys and checks run
24 uncontained, as with `FORGE_CELLS=off`. The suites that are about sockets and
25 cells themselves skip in port mode and show as skipped: stale-socket recovery
26 and two forges on one data directory (`test/hardening.test.ts`), the egress
27 proxy and the cell-limit and probe suites (`test/contain.test.ts`), and the
28 socket-copy test in `test/apps-adversarial.test.ts`. The forge's check runs
29 them on every push and sends the log back.
30
31 Not proven from inside a sandbox: that a sandboxed shell reaches its own server
32 and is still refused `localhost` outside the sandbox and every `denyRead` path.
33 Nesting a sandbox is not possible there, so `test/crew-sandbox.test.ts` checks
34 the settings that enforce it (`allowLocalBinding` on, `allowAllUnixSockets` off,
35 the `denyRead` list) and not the boundary itself.
src/app.ts +9 −1
@@ -7,6 +7,7 @@
77 import type { MailConfig, Mailer } from "./kernel/mail.ts";
88 import type { Database } from "bun:sqlite";
99 import type { Decider } from "./crew/decide.ts";
10 import { randomBytes } from "node:crypto";
1011 import { mkdirSync } from "node:fs";
1112 import { join, resolve } from "node:path";
1213 import { migrations } from "./core/schema.ts";
@@ -133,6 +134,10 @@ export interface App {
133134 readonly log: Log;
134135 readonly reposRoot: string;
135136 readonly socketPath: string;
137 /** Where the internal listener answers: `socketPath`, or a loopback port (`git/hook-address.ts`). */
138 readonly hookAddress: { current: string };
139 /** Per-boot secret every internal request must carry (`FORGE_HOOK_SECRET` in a hook's env). */
140 readonly hookSecret: string;
136141 close(): void;
137142 }
138143
@@ -144,6 +149,7 @@ export function createApp(config: ForgeConfig): App {
144149 const dataDir = resolve(config.dataDir);
145150 const reposRoot = join(dataDir, "repos");
146151 mkdirSync(reposRoot, { recursive: true });
152 const socketPath = join(dataDir, `forge-${id()}.sock`);
147153
148154 const db = openDb(join(dataDir, "forge.db"), migrations);
149155 // Hand-installed new-account guards become the `guard_new_accounts`
@@ -168,7 +174,9 @@ export function createApp(config: ForgeConfig): App {
168174 reposRoot,
169175 // This process's own: two forges share a data directory for the length
170176 // of a release, and each spawns git with its own socket in the env.
171 socketPath: join(dataDir, `forge-${id()}.sock`),
177 socketPath,
178 hookAddress: { current: socketPath },
179 hookSecret: randomBytes(32).toString("hex"),
172180 close: () => {
173181 // The pooled `cat-file` readers for this forge's repos are processes,
174182 // and processes outlive a closed database unless somebody says
src/apps/contain.ts +5
@@ -33,6 +33,7 @@ import {
3333 } from "node:fs";
3434 import { availableParallelism, tmpdir } from "node:os";
3535 import { dirname, join } from "node:path";
36 import { portInsteadOfSocket } from "../git/hook-address.ts";
3637 import { relay, startEgress, type Egress } from "./egress.ts";
3738
3839 export interface Limits {
@@ -154,6 +155,10 @@ export function cellsAvailable(): Promise<
154155 async function probe(): Promise<{ ok: true } | { ok: false; reason: string }> {
155156 if (process.env["FORGE_CELLS"] === "off")
156157 return { ok: false, reason: "FORGE_CELLS=off" };
158 // A cell reaches its proxy through a unix socket; a sandbox that put the
159 // forge on loopback ports (a crew run's `bun test`) cannot bind one.
160 if (portInsteadOfSocket())
161 return { ok: false, reason: "FORGE_TEST_SOCKET=port: no unix sockets" };
157162 if (process.platform !== "linux")
158163 return { ok: false, reason: `no cells on ${process.platform}` };
159164 for (const tool of ["bwrap", "socat"])
src/crew/brief.ts +1 −1
@@ -120,7 +120,7 @@ export function systemPrompt(input: BriefInput): string {
120120 }
121121 lines.push(
122122 "",
123 "Your shell is sandboxed: writes stay in the clone, the network is the forge and the package registry, and you cannot signal processes you started in an earlier command. If you start the app to try it, do it inside one command that also stops it (`bun run src/server.ts & pid=$!; sleep 2; curl …; kill $pid`). Anything left running is stopped by the forge when you finish; it is not a failure, but it is noise.",
123 "Your shell is sandboxed: writes stay in the clone, the network is the forge and the package registry, and you cannot signal processes you started in an earlier command. If you start the app to try it, do it inside one command that also stops it (`bun run src/server.ts & pid=$!; sleep 2; curl …; kill $pid`). Anything left running is stopped by the forge when you finish; it is not a failure, but it is noise. Never leave the sandbox: it cannot be asked to. Inside it you can run the type check and `bun test` (FORGE_TEST_SOCKET=port is already set, so servers listen on loopback ports in place of unix sockets). The suites about unix sockets and cells themselves (stale-socket recovery, the egress proxy, cell limits) are skipped in that mode and named as skipped; the forge's check runs them on every push. Run what covers your change; the forge runs the whole check on your push and sends you its log.",
outdated

This says bun test runs inside the sandbox. At this head, in port mode, apps-adversarial, apps-token and contain fail with Failed to listen at …/out.sock (src/apps/egress.ts) and on app sockets. Either make them run or name exactly what the check runs instead. An agent told everything is green that then sees 24 red tests will chase them.

124124 "",
125125 "Work carefully and finish. If the task is unclear, impossible, or not something you should do, do not guess: make your last message start with `DECLINED:` followed by exactly why. Nothing is pushed then, and a person reads it.",
126126 );
src/crew/runner.ts +3
@@ -921,6 +921,9 @@ async function work(
921921 env["CLAUDE_CODE_TMPDIR"] = runTmp;
922922 env["TMPDIR"] = runTmp;
923923 }
924 // The sandbox refuses a unix socket's bind; the forge's own servers take
925 // a loopback port instead (`git/hook-address.ts`), so `bun test` runs.
926 if (sandbox.enforced) env["FORGE_TEST_SOCKET"] = "port";

No test would fail without this line. Assert that a sandboxed run's environment carries FORGE_TEST_SOCKET=port and that an unsandboxed run's does not.

924927 // One deadline for the run, whatever it takes to get there: the token
925928 // dies ten minutes after it, so a second invocation gets what is left.
926929 const deadline = Date.now() + options.runTimeoutMs;
src/git/hook-address.ts added +42
@@ -0,0 +1,42 @@
1 /**
2 * Where the forge's internal listener answers: a unix socket path, or
3 * `tcp:<host>:<port>` on loopback. Production uses the socket. A sandbox
4 * that refuses the bind (an agent running `bun test`) sets
5 * `FORGE_TEST_SOCKET=port` and the listener takes a loopback port instead,
6 * so no unix socket is ever exposed to the sandbox to get there.
7 */
8
9 const TCP = /^tcp:(127\.0\.0\.1):([0-9]{1,5})$/;
10
11 /** The address of a loopback listener. */
12 export function tcpAddress(port: number): string {
13 return `tcp:127.0.0.1:${port}`;
14 }
15
16 /** Whether the environment asks for a loopback port in place of a unix socket. */
17 export function portInsteadOfSocket(
18 env: Record<string, string | undefined> = process.env,
19 ): boolean {
20 return env["FORGE_TEST_SOCKET"] === "port";
21 }
22
23 /** The URL and fetch option that reach `path` at `address`. */
24 export function internalRequest(
25 address: string,
26 path: string,
27 ): { url: string; init: { unix?: string } } {
28 const tcp = TCP.exec(address);
29 if (tcp !== null)
30 return { url: `http://${tcp[1]}:${tcp[2]}${path}`, init: {} };
31 return { url: `http://forge${path}`, init: { unix: address } };
32 }
33
34 /** `fetch` against the internal listener, wherever it is. */
35 export function fetchInternal(
36 address: string,
37 path: string,
38 init: RequestInit = {},
39 ): Promise<Response> {
40 const target = internalRequest(address, path);
41 return fetch(target.url, { ...init, ...target.init } as RequestInit);
42 }
src/git/hook-protocol.ts +14
@@ -24,12 +24,26 @@ export interface HookRequest {
2424 readonly gitEnv: Record<string, string>;
2525 }
2626
27 /** The header that carries the per-boot secret to the internal listener. */
28 export const HOOK_SECRET_HEADER = "x-forge-hook-secret";
29
2730 export const FORWARDED_GIT_ENV = [
2831 "GIT_OBJECT_DIRECTORY",
2932 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
3033 "GIT_QUARANTINE_PATH",
3134 ] as const;
3235
36 /** Only the quarantine variables a hook may forward reach git. */
37 export function forwardedGitEnv(env: unknown): Record<string, string> {
38 const kept: Record<string, string> = {};
39 if (typeof env !== "object" || env === null) return kept;
40 for (const name of FORWARDED_GIT_ENV) {
41 const value = (env as Record<string, unknown>)[name];
42 if (typeof value === "string") kept[name] = value;
43 }
44 return kept;
45 }
46
3347 export function parseHookStdin(text: string): RefUpdate[] {
3448 const updates: RefUpdate[] = [];
3549 for (const line of text.split("\n")) {
src/git/hooks.ts +13 −4
@@ -10,7 +10,12 @@
1010
1111 import { chmod } from "node:fs/promises";
1212 import { join } from "node:path";
13 import { FORWARDED_GIT_ENV, parseHookStdin } from "./hook-protocol.ts";
13 import { fetchInternal } from "./hook-address.ts";
14 import {
15 FORWARDED_GIT_ENV,
16 HOOK_SECRET_HEADER,
17 parseHookStdin,
18 } from "./hook-protocol.ts";
1419
1520 const HOOK_NAMES = ["pre-receive", "post-receive"] as const;
1621 type HookName = (typeof HOOK_NAMES)[number];
@@ -52,6 +57,7 @@ function selfInvocation(): { runtime: string; entry: string | undefined } {
5257 /** Env for a receive-pack spawn so its hooks can reach back into the forge. */
5358 export function hookEnv(input: {
5459 socketPath: string;
60 secret: string;
5561 repoId: string;
5662 actorId: string;
5763 }): Record<string, string> {
@@ -59,6 +65,7 @@ export function hookEnv(input: {
5965 const env: Record<string, string> = {
6066 FORGE_RUNTIME: self.runtime,
6167 FORGE_SOCKET: input.socketPath,
68 FORGE_HOOK_SECRET: input.secret,
6269 FORGE_REPO_ID: input.repoId,
6370 FORGE_ACTOR_ID: input.actorId,
6471 };
@@ -97,10 +104,12 @@ export async function runHookClient(hook: HookName): Promise<never> {
97104 }
98105
99106 try {
100 const response = await fetch("http://forge/internal/hook", {
101 unix: socket,
107 const response = await fetchInternal(socket, "/internal/hook", {
102108 method: "POST",
103 headers: { "content-type": "application/json" },
109 headers: {
110 "content-type": "application/json",
111 [HOOK_SECRET_HEADER]: process.env.FORGE_HOOK_SECRET ?? "",
112 },
104113 // A forge that has stopped answering must not become a push that never
105114 // returns. Without a deadline this relay waits forever, and the git
106115 // process holding the repo's ref lock waits with it — one wedged forge
src/http/server.ts +25 −3
@@ -53,6 +53,11 @@ import {
5353 serviceRpc,
5454 type GitService,
5555 } from "../git/smart-http.ts";
56 import { timingSafeEqual } from "node:crypto";
57 import {
58 forwardedGitEnv,
59 HOOK_SECRET_HEADER,
60 } from "../git/hook-protocol.ts";
5661 import { lstat, readdir, rm } from "node:fs/promises";
5762 import { rmSync } from "node:fs";
5863 import { join } from "node:path";
@@ -399,6 +404,7 @@ import {
399404 resolveAppHost,
400405 } from "../apps/proxy.ts";
401406
407 import { portInsteadOfSocket, tcpAddress } from "../git/hook-address.ts";
402408 import { closeHostedChildren, resolveHostedChild } from "../spawn/host.ts";
403409 import { getVerifiedDomain } from "../core/domains.ts";
404410
@@ -812,7 +818,8 @@ export function buildRouter(): Router<Handler> {
812818 withGitAccess(ctx, "git-receive-pack", (dir, repoId) =>
813819 serviceRpc(dir, "git-receive-pack", ctx.request, {
814820 env: hookEnv({
815 socketPath: ctx.app.socketPath,
821 socketPath: ctx.app.hookAddress.current,
822 secret: ctx.app.hookSecret,
816823 repoId,
817824 actorId: ctx.auth.user?.id ?? "",
818825 }),
@@ -1379,8 +1386,16 @@ const fromStored = (stored: StoredResponse, replay: boolean): Response =>
13791386
13801387 // ---- the internal server (unix socket, hooks only) ----
13811388
1389 /** Whether the request carries this boot's hook secret (constant-time). */
1390 function hookCallerAllowed(app: App, request: Request): boolean {
1391 const given = Buffer.from(request.headers.get(HOOK_SECRET_HEADER) ?? "");
1392 const want = Buffer.from(app.hookSecret);
1393 return given.length === want.length && timingSafeEqual(given, want);
1394 }
1395
13821396 async function handleHook(app: App, request: Request): Promise<Response> {
1383 const input = (await request.json()) as HookRequest;
1397 const parsed = (await request.json()) as HookRequest;
outdated

Good hardening. It is the hook protocol's concept, though: move it to src/git/hook-protocol.ts next to FORWARDED_GIT_ENV rather than exporting a helper from the server module.

The secret gates this, so it is low. The body is still cast rather than checked, though: a null or non-object body throws here and comes back as a 500. A shape check returning 400 would match the validate-at-the-boundary rule now that this listener can be a TCP port.

1398 const input: HookRequest = { ...parsed, gitEnv: forwardedGitEnv(parsed.gitEnv) };
13841399 const repo = getRepoById(app.db, input.repoId);
13851400 if (!repo) return new Response("unknown repository", { status: 400 });
13861401
@@ -1716,9 +1731,15 @@ export async function serve(app: App): Promise<Servers> {
17161731 },
17171732 });
17181733
1734 const onPort = portInsteadOfSocket();
17191735 const internal = Bun.serve({
1720 unix: app.socketPath,
1736 ...(onPort ? { hostname: "127.0.0.1", port: 0 } : { unix: app.socketPath }),
17211737 fetch(request) {
1738 // The socket's file mode used to be the only door check; a loopback
1739 // port has none, so every internal request proves the boot secret.
1740 if (!hookCallerAllowed(app, request)) {
1741 return new Response("forbidden", { status: 403 });
1742 }
17221743 const url = new URL(request.url);
17231744 if (request.method === "POST" && url.pathname === "/internal/hook") {
17241745 return handleHook(app, request);
@@ -1727,6 +1748,7 @@ export async function serve(app: App): Promise<Servers> {
17271748 },
17281749 });
17291750
1751 if (onPort) app.hookAddress.current = tcpAddress(internal.port ?? 0);
17301752 const port = web.port ?? 0;
17311753 return {
17321754 url: `http://localhost:${port}`,
src/tui/ssh.ts +2 −1
@@ -274,7 +274,8 @@ async function serveGitCommand(
274274 const dir = repoDir(forge.reposRoot, repo.ownerName, repo.name);
275275 const env: Record<string, string> = write
276276 ? hookEnv({
277 socketPath: forge.socketPath,
277 socketPath: forge.hookAddress.current,
278 secret: forge.hookSecret,
278279 repoId: repo.id,
279280 actorId: user?.id ?? "",
280281 })
test/accounts.test.ts +3 −2
@@ -10,6 +10,7 @@ import {
1010 type Notifier,
1111 } from "../src/crew/notify.ts";
1212 import { queueAgentRun } from "../src/crew/dispatch.ts";
13 import { fetchInternal } from "../src/git/hook-address.ts";
1314 import { addComment, openIssue } from "../src/actions/issues.ts";
1415 import { createRepo } from "../src/actions/repos.ts";
1516 import { signUp } from "../src/actions/users.ts";
@@ -343,9 +344,9 @@ describe("accounts: the owner sees, suspends and removes", () => {
343344 // What git's pre-receive hook asks the forge once the pack is in: the
344345 // ref update, from the account that pushed it.
345346 const ask = () =>
346 fetch("http://forge/internal/hook", {
347 unix: app.socketPath,
347 fetchInternal(app.hookAddress.current, "/internal/hook", {
348348 method: "POST",
349 headers: { "x-forge-hook-secret": app.hookSecret },
349350 body: JSON.stringify({
350351 hook: "pre-receive",
351352 repoId: made.value.id,
test/apps-adversarial.test.ts +5 −1
@@ -69,6 +69,9 @@ import {
6969 } from "../src/runs/log.ts";
7070 import { grantedCookie } from "./helpers/identity.ts";
7171 import { gitClientEnv } from "./helpers/git-client.ts";
72 import { portInsteadOfSocket } from "../src/git/hook-address.ts";
73
74 const testBySocket = portInsteadOfSocket() ? test.skip : test;
7275
7376 const DOMAIN = "apps.test";
7477
@@ -634,7 +637,8 @@ describe("ensureEnvData path safety", () => {
634637 );
635638 });
636639
637 test("a copy of an app's data leaves its sockets behind", async () => {
640 // Binds a unix socket itself; the check runs it where a sandbox refuses.
641 testBySocket("a copy of an app's data leaves its sockets behind", async () => {
638642 // An app that keeps a socket in its data — a forge does — must not
639643 // make a preview impossible, let alone take the forge down with it.
640644 const source = join(dataDir, "with-socket");
test/contain.test.ts +9 −3
@@ -47,6 +47,11 @@ import type { Repo } from "../src/core/repos.ts";
4747 import { serve, type Servers } from "../src/http/server.ts";
4848 import { readRunLog } from "../src/runs/log.ts";
4949 import { missing } from "./helpers/tools.ts";
50 import { portInsteadOfSocket } from "../src/git/hook-address.ts";
51
52 // Cells and their egress proxy bind unix sockets, which a sandbox refuses
53 // (FORGE_TEST_SOCKET=port); these run in the forge's check instead.
54 const bySocket = portInsteadOfSocket() ? describe.skip : describe;
5055
5156 describe("egress: what a cell may reach", () => {
5257 test("a declared name matches exactly, or below a *. wildcard, on 80 and 443", () => {
@@ -145,7 +150,7 @@ describe("egress: what a cell may reach", () => {
145150 });
146151 });
147152
148 describe("egress: the proxy forwards what is allowed, byte for byte, and refuses the rest", () => {
153 bySocket("egress: the proxy forwards what is allowed, byte for byte, and refuses the rest", () => {
149154 let dir: string;
150155 let upstream: ReturnType<typeof Bun.serve>;
151156 let socket: string;
@@ -253,7 +258,7 @@ describe("egress: the proxy forwards what is allowed, byte for byte, and refuses
253258 });
254259 });
255260
256 describe("egress, reported: an undeclared public host goes through and is counted; a private one never does", () => {
261 bySocket("egress, reported: an undeclared public host goes through and is counted; a private one never does", () => {
257262 let dir: string;
258263 let socket: string;
259264 let egress: { close(): void };
@@ -473,7 +478,8 @@ describe("cells hide every mount that may carry a secret", () => {
473478 const noCells =
474479 process.env["FORGE_CELLS"] === "off" ||
475480 missing("bwrap", "socat") ||
476 process.platform !== "linux";
481 process.platform !== "linux" ||
482 portInsteadOfSocket();
477483
478484 /** What the probe tries, from inside a deploy or a check. */
479485 function probeSource(targets: {
test/crew-sandbox.test.ts +196
@@ -20,6 +20,18 @@ import { createRepo } from "../src/actions/repos.ts";
2020 import { createRun, getRun, noteRunProgress } from "../src/core/runs.ts";
2121 import { createUser } from "../src/core/users.ts";
2222 import { sandboxLine } from "../src/web/studio/sandbox.ts";
23 import {
24 fetchInternal,
25 internalRequest,
26 tcpAddress,
27 } from "../src/git/hook-address.ts";
28 import { addToCrew, createAgent } from "../src/actions/crew.ts";
29 import { assignIssue, openIssue } from "../src/actions/issues.ts";
30 import { commitToBranch } from "../src/actions/repos.ts";
31 import { startCrew } from "../src/crew/dispatch.ts";
32 import { listRuns } from "../src/core/runs.ts";
33 import { serve } from "../src/http/server.ts";
34 import { readFileSync } from "node:fs";
2335 import { claudeSettings } from "../src/crew/claude.ts";
2436 import {
2537 dataInTemp,
@@ -225,3 +237,187 @@ describe("the shell sandbox denies the whole data directory but the agent's own
225237 expect(denied(join(dataDir, "crew/quill"), policy().denyRead)).toBe(true);
226238 });
227239 });
240
241 describe("a sandboxed run can run this repository's tests (#1107)", () => {

The issue asks this file to prove that a sandboxed run reaches its own server and is still refused localhost outside the sandbox and every denyRead path. These tests check settings keys and the env variable, not the boundary. Add the proof, or state plainly why it cannot run here and open the follow-up issue.

242 test("the settings keep the boundary: no unix socket is let through", () => {
243 const settings = claudeSettings(
244 sandboxPolicy({
outdated

A 404 on /internal/nothing shows the listener is bound, not that the hook works or is gated. Assert that an unauthenticated /internal/hook gets a 403 and that one carrying the secret is answered.

245 dataDir: "/tmp/forge-sandbox-test",
246 agentName: "scout",
247 urls: [],
248 runId: "01abcdefghijklmnop",
249 }),
250 ).sandbox as { network: Record<string, unknown> };
251 expect(settings.network["allowAllUnixSockets"]).toBeUndefined();
252 expect(settings.network["allowUnixSockets"]).toBeUndefined();
253 });
254
255 test("in port mode no cell is offered: its proxy socket cannot be bound", async () => {
256 const script = `import { cellsAvailable } from "./src/apps/contain.ts"; console.log(JSON.stringify(await cellsAvailable()))`;
257 const run = Bun.spawnSync([process.execPath, "-e", script], {
258 env: { ...process.env, FORGE_TEST_SOCKET: "port", FORGE_CELLS: "" },
259 cwd: join(import.meta.dir, ".."),
260 });
261 const answer = JSON.parse(run.stdout.toString());
262 expect(answer.ok).toBe(false);
263 expect(answer.reason).toContain("FORGE_TEST_SOCKET=port");
264 });
265
266 test("the internal listener takes a loopback port where the socket cannot be bound, and gates it", async () => {
267 const was = process.env["FORGE_TEST_SOCKET"];
268 process.env["FORGE_TEST_SOCKET"] = "port";
269 const dataDir = await mkdtemp(join(tmpdir(), "forge-sandbox-port-"));
270 const app = createApp({ dataDir, port: 0 });
271 const { serve } = await import("../src/http/server.ts");

serve is already imported at the top of the file. The dynamic import here is redundant, and the same goes for the await import of forwardedGitEnv and systemPrompt below. The file also imports from ../src/actions/repos.ts and ../src/core/runs.ts twice; fold those into the existing import lines.

272 const servers = await serve(app);
273 try {
274 const address = app.hookAddress.current;
275 expect(address).toMatch(/^tcp:127\.0\.0\.1:\d+$/);
276 expect(existsSync(app.socketPath)).toBe(false);
277 const post = (headers: Record<string, string>) =>
278 fetchInternal(address, "/internal/hook", {
279 method: "POST",
280 headers,
281 body: JSON.stringify({ hook: "pre-receive", repoId: "nope", actorId: "", updates: [], gitEnv: {} }),
282 });
283 expect((await post({})).status).toBe(403);
284 expect((await post({ "x-forge-hook-secret": "wrong" })).status).toBe(403);
285 expect((await fetchInternal(address, "/internal/nothing")).status).toBe(403);
286 // With the secret the hook is answered (an unknown repository: 400).
287 const ok = await post({ "x-forge-hook-secret": app.hookSecret });
288 expect(ok.status).toBe(400);
289 expect(await ok.text()).toBe("unknown repository");
290 } finally {
291 servers.stop();
292 app.close();
293 await rm(dataDir, { recursive: true, force: true });
294 if (was === undefined) delete process.env["FORGE_TEST_SOCKET"];
295 else process.env["FORGE_TEST_SOCKET"] = was;
296 }
297 });
298
299 test("a hook's git environment is cut to the quarantine variables", async () => {
300 const { forwardedGitEnv } = await import("../src/git/hook-protocol.ts");
301 expect(
302 forwardedGitEnv({
303 GIT_OBJECT_DIRECTORY: "/q",
304 GIT_DIR: "/etc",
305 GIT_CONFIG_COUNT: "1",
306 GIT_QUARANTINE_PATH: 5,
307 }),
308 ).toEqual({ GIT_OBJECT_DIRECTORY: "/q" });
309 expect(forwardedGitEnv(null)).toEqual({});
310 });
311
312 test("addresses are a socket path or loopback only", () => {
313 expect(internalRequest("/d/forge-x.sock", "/p")).toEqual({
314 url: "http://forge/p",
315 init: { unix: "/d/forge-x.sock" },
316 });
317 expect(internalRequest(tcpAddress(4321), "/p").url).toBe(
318 "http://127.0.0.1:4321/p",
319 );
320 // Not loopback: treated as a path, never as a host to dial.
321 expect(internalRequest("tcp:10.0.0.1:80", "/p").url).toBe("http://forge/p");
322 });
323
324 test("the brief tells the agent what it can run and never to leave the sandbox", async () => {
325 const { systemPrompt } = await import("../src/crew/brief.ts");
326 const prompt = systemPrompt({
327 agent: { user: { username: "bot", displayName: "" }, brief: "" },
328 repo: { ownerName: "alice", name: "square", defaultBranch: "main" },
329 issue: { number: 1 },
330 branch: "bot/1",
331 forgeUrl: "http://forge.test",
332 isApp: false,
333 } as never);
334 expect(prompt).toContain("Never leave the sandbox");
335 expect(prompt).toContain("FORGE_TEST_SOCKET=port");
336 expect(prompt).not.toMatch(/sandbox (off|disabled)|outside the sandbox/i);
337 });
338 });
339
340 describe("a sandboxed run pushes through a port-mode forge (#1107)", () => {
341 async function runWith() {
342 const was = process.env["FORGE_TEST_SOCKET"];
343 process.env["FORGE_TEST_SOCKET"] = "port";
344 const dataDir = await mkdtemp(join(tmpdir(), "forge-sandbox-push-"));
345 const app = createApp({ dataDir, port: 0 });
346 const servers = await serve(app);
347 // Bound; from here only the runner may put it in a run's environment.
348 delete process.env["FORGE_TEST_SOCKET"];
349 const seen = join(dataDir, "seen");
350 const fake = join(dataDir, "fake-claude");
351 writeFileSync(
352 fake,
353 `#!/bin/sh
354 if [ "$1" = "--version" ]; then echo "2.1.0 (fake)"; exit 0; fi
355 IFS= read -r first
356 echo "\${FORGE_TEST_SOCKET:-unset}" > "${seen}.env"
357 echo "x" >> W.md; git add -A; git commit -qm w
358 git push origin HEAD:refs/pull/9 > "${seen}.pull" 2>&1
359 git push -q -u origin HEAD > "${seen}.branch" 2>&1
360 echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total_cost_usd":0.01,"num_turns":1,"session_id":"s","terminal_reason":"completed"}'
361 `,
362 );
363 chmodSync(fake, 0o755);
364 const crew = await startCrew(app, {
365 forgeUrl: servers.url,
366 localUrl: servers.url,
367 binary: fake,
368 concurrency: 1,
369 budgetUsd: 1,
370 runTimeoutMs: 60_000,
371 });
372 try {
373 const alice = await createUser(app.db, { trusted: true,
374 username: "alice",
375 email: "[email protected]",
376 password: "a-good-passphrase",
377 adminIfFirst: true,
378 });
379 if (!alice.ok) throw new Error(alice.error);
380 const made = await createAgent(app, alice.value, { username: "bot", brief: "b" });
381 if (!made.ok) throw new Error(String(made.error));
382 const repo = await createRepo(app, alice.value, { name: "garden" });
383 if (!repo.ok) throw new Error(String(repo.error));
384 const seeded = await commitToBranch(app, alice.value, repo.value, {
385 message: "Start",
386 files: [{ path: "README.md", content: "# g\n" }],
387 });
388 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
389 addToCrew(app, alice.value, repo.value, "bot");
390 const issue = openIssue(app, alice.value, repo.value, { title: "Plant", body: "x" });
391 if (!issue.ok) throw new Error(String(issue.error));
392 assignIssue(app, alice.value, repo.value, issue.value, made.value.user);
393 const deadline = Date.now() + 30_000;
394 while (Date.now() < deadline) {
395 const run = listRuns(app.db, repo.value.id, { kind: "agent" })[0];
396 if (run && ["ok", "failed", "cancelled"].includes(run.state)) break;
397 await Bun.sleep(100);
398 }
399 const read = (n: string) => (existsSync(`${seen}.${n}`) ? readFileSync(`${seen}.${n}`, "utf8") : "");
400 return { env: read("env").trim(), pull: read("pull"), branch: read("branch"), address: app.hookAddress.current };
401 } finally {
402 await crew.stop();
403 servers.stop();
404 app.close();
405 await rm(dataDir, { recursive: true, force: true });
406 if (was === undefined) delete process.env["FORGE_TEST_SOCKET"];
407 else process.env["FORGE_TEST_SOCKET"] = was;
408 }
409 }
410
411 // `startCrew` probes the host itself, so the run is sandboxed exactly
412 // where this host can hold it to one; the env must follow that.
413 test("a sandboxed run carries FORGE_TEST_SOCKET=port, an unsandboxed one does not; a real push meets the pre-receive verdict over the port", async () => {
414 const enforced = (await sandboxAvailable()).ok;
415 const r = await runWith();
416 expect(r.env).toBe(enforced ? "port" : "unset");
417 expect(r.address).toMatch(/^tcp:/);
418 expect(r.pull).toContain("refs/pull/* is reserved");
419 // The ordinary branch push went through the same door.
420 expect(r.branch).not.toContain("hook relay failed");
421 expect(r.branch).not.toContain("forbidden");
422 }, 60_000);
423 });
test/hardening.test.ts +7 −2
@@ -5,6 +5,7 @@
55 */
66
77 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { portInsteadOfSocket } from "../src/git/hook-address.ts";
89 import { lstat, mkdtemp, rm } from "node:fs/promises";
910 import { tmpdir } from "node:os";
1011 import { join } from "node:path";
@@ -82,7 +83,11 @@ describe("reconcile refreshes hooks (H-1)", () => {
8283 });
8384 });
8485
85 describe("two forges on one data directory", () => {
86 // These two are about unix socket files themselves; where the sandbox
87 // refuses the bind (FORGE_TEST_SOCKET=port) the check runs them instead.
88 const bySocket = portInsteadOfSocket() ? describe.skip : describe;
89
90 bySocket("two forges on one data directory", () => {
8691 test("a socket another forge still answers on is left alone, and this forge boots beside it", async () => {
8792 const dataDir = await mkdtemp(join(tmpdir(), "forge-harden-share-"));
8893 const app = createApp({ dataDir, port: 0 });
@@ -142,7 +147,7 @@ describe("two forges on one data directory", () => {
142147 });
143148 });
144149
145 describe("stale socket recovery (H-2)", () => {
150 bySocket("stale socket recovery (H-2)", () => {
146151 test("a real SIGKILL-orphaned socket inode does not block boot", async () => {
147152 const dataDir = await mkdtemp(join(tmpdir(), "forge-harden-sock-"));
148153 const app = createApp({ dataDir, port: 0 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs