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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1107-a-sandboxed-agent-can-run-this-repositor → main 3 commits fork 9 threads · 9 unresolved
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 | |
| 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 @@ | ||
| 7 | 7 | import type { MailConfig, Mailer } from "./kernel/mail.ts"; |
| 8 | 8 | import type { Database } from "bun:sqlite"; |
| 9 | 9 | import type { Decider } from "./crew/decide.ts"; |
| 10 | import { randomBytes } from "node:crypto"; | |
| 10 | 11 | import { mkdirSync } from "node:fs"; |
| 11 | 12 | import { join, resolve } from "node:path"; |
| 12 | 13 | import { migrations } from "./core/schema.ts"; |
| @@ -133,6 +134,10 @@ export interface App { | ||
| 133 | 134 | readonly log: Log; |
| 134 | 135 | readonly reposRoot: string; |
| 135 | 136 | 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; | |
| 136 | 141 | close(): void; |
| 137 | 142 | } |
| 138 | 143 | |
| @@ -144,6 +149,7 @@ export function createApp(config: ForgeConfig): App { | ||
| 144 | 149 | const dataDir = resolve(config.dataDir); |
| 145 | 150 | const reposRoot = join(dataDir, "repos"); |
| 146 | 151 | mkdirSync(reposRoot, { recursive: true }); |
| 152 | const socketPath = join(dataDir, `forge-${id()}.sock`); | |
| 147 | 153 | |
| 148 | 154 | const db = openDb(join(dataDir, "forge.db"), migrations); |
| 149 | 155 | // Hand-installed new-account guards become the `guard_new_accounts` |
| @@ -168,7 +174,9 @@ export function createApp(config: ForgeConfig): App { | ||
| 168 | 174 | reposRoot, |
| 169 | 175 | // This process's own: two forges share a data directory for the length |
| 170 | 176 | // 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"), | |
| 172 | 180 | close: () => { |
| 173 | 181 | // The pooled `cat-file` readers for this forge's repos are processes, |
| 174 | 182 | // and processes outlive a closed database unless somebody says |
src/apps/contain.ts +5
| @@ -33,6 +33,7 @@ import { | ||
| 33 | 33 | } from "node:fs"; |
| 34 | 34 | import { availableParallelism, tmpdir } from "node:os"; |
| 35 | 35 | import { dirname, join } from "node:path"; |
| 36 | import { portInsteadOfSocket } from "../git/hook-address.ts"; | |
| 36 | 37 | import { relay, startEgress, type Egress } from "./egress.ts"; |
| 37 | 38 | |
| 38 | 39 | export interface Limits { |
| @@ -154,6 +155,10 @@ export function cellsAvailable(): Promise< | ||
| 154 | 155 | async function probe(): Promise<{ ok: true } | { ok: false; reason: string }> { |
| 155 | 156 | if (process.env["FORGE_CELLS"] === "off") |
| 156 | 157 | 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" }; | |
| 157 | 162 | if (process.platform !== "linux") |
| 158 | 163 | return { ok: false, reason: `no cells on ${process.platform}` }; |
| 159 | 164 | for (const tool of ["bwrap", "socat"]) |
src/crew/brief.ts +1 −1
| @@ -120,7 +120,7 @@ export function systemPrompt(input: BriefInput): string { | ||
| 120 | 120 | } |
| 121 | 121 | lines.push( |
| 122 | 122 | "", |
| 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 |
||
| 124 | 124 | "", |
| 125 | 125 | "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.", |
| 126 | 126 | ); |
src/crew/runner.ts +3
| @@ -921,6 +921,9 @@ async function work( | ||
| 921 | 921 | env["CLAUDE_CODE_TMPDIR"] = runTmp; |
| 922 | 922 | env["TMPDIR"] = runTmp; |
| 923 | 923 | } |
| 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 |
||
| 924 | 927 | // One deadline for the run, whatever it takes to get there: the token |
| 925 | 928 | // dies ten minutes after it, so a second invocation gets what is left. |
| 926 | 929 | 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 { | ||
| 24 | 24 | readonly gitEnv: Record<string, string>; |
| 25 | 25 | } |
| 26 | 26 | |
| 27 | /** The header that carries the per-boot secret to the internal listener. */ | |
| 28 | export const HOOK_SECRET_HEADER = "x-forge-hook-secret"; | |
| 29 | ||
| 27 | 30 | export const FORWARDED_GIT_ENV = [ |
| 28 | 31 | "GIT_OBJECT_DIRECTORY", |
| 29 | 32 | "GIT_ALTERNATE_OBJECT_DIRECTORIES", |
| 30 | 33 | "GIT_QUARANTINE_PATH", |
| 31 | 34 | ] as const; |
| 32 | 35 | |
| 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 | ||
| 33 | 47 | export function parseHookStdin(text: string): RefUpdate[] { |
| 34 | 48 | const updates: RefUpdate[] = []; |
| 35 | 49 | for (const line of text.split("\n")) { |
src/git/hooks.ts +13 −4
| @@ -10,7 +10,12 @@ | ||
| 10 | 10 | |
| 11 | 11 | import { chmod } from "node:fs/promises"; |
| 12 | 12 | 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"; | |
| 14 | 19 | |
| 15 | 20 | const HOOK_NAMES = ["pre-receive", "post-receive"] as const; |
| 16 | 21 | type HookName = (typeof HOOK_NAMES)[number]; |
| @@ -52,6 +57,7 @@ function selfInvocation(): { runtime: string; entry: string | undefined } { | ||
| 52 | 57 | /** Env for a receive-pack spawn so its hooks can reach back into the forge. */ |
| 53 | 58 | export function hookEnv(input: { |
| 54 | 59 | socketPath: string; |
| 60 | secret: string; | |
| 55 | 61 | repoId: string; |
| 56 | 62 | actorId: string; |
| 57 | 63 | }): Record<string, string> { |
| @@ -59,6 +65,7 @@ export function hookEnv(input: { | ||
| 59 | 65 | const env: Record<string, string> = { |
| 60 | 66 | FORGE_RUNTIME: self.runtime, |
| 61 | 67 | FORGE_SOCKET: input.socketPath, |
| 68 | FORGE_HOOK_SECRET: input.secret, | |
| 62 | 69 | FORGE_REPO_ID: input.repoId, |
| 63 | 70 | FORGE_ACTOR_ID: input.actorId, |
| 64 | 71 | }; |
| @@ -97,10 +104,12 @@ export async function runHookClient(hook: HookName): Promise<never> { | ||
| 97 | 104 | } |
| 98 | 105 | |
| 99 | 106 | try { |
| 100 | const response = await fetch("http://forge/internal/hook", { | |
| 101 | unix: socket, | |
| 107 | const response = await fetchInternal(socket, "/internal/hook", { | |
| 102 | 108 | 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 | }, | |
| 104 | 113 | // A forge that has stopped answering must not become a push that never |
| 105 | 114 | // returns. Without a deadline this relay waits forever, and the git |
| 106 | 115 | // process holding the repo's ref lock waits with it — one wedged forge |
src/http/server.ts +25 −3
| @@ -53,6 +53,11 @@ import { | ||
| 53 | 53 | serviceRpc, |
| 54 | 54 | type GitService, |
| 55 | 55 | } 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"; | |
| 56 | 61 | import { lstat, readdir, rm } from "node:fs/promises"; |
| 57 | 62 | import { rmSync } from "node:fs"; |
| 58 | 63 | import { join } from "node:path"; |
| @@ -399,6 +404,7 @@ import { | ||
| 399 | 404 | resolveAppHost, |
| 400 | 405 | } from "../apps/proxy.ts"; |
| 401 | 406 | |
| 407 | import { portInsteadOfSocket, tcpAddress } from "../git/hook-address.ts"; | |
| 402 | 408 | import { closeHostedChildren, resolveHostedChild } from "../spawn/host.ts"; |
| 403 | 409 | import { getVerifiedDomain } from "../core/domains.ts"; |
| 404 | 410 | |
| @@ -812,7 +818,8 @@ export function buildRouter(): Router<Handler> { | ||
| 812 | 818 | withGitAccess(ctx, "git-receive-pack", (dir, repoId) => |
| 813 | 819 | serviceRpc(dir, "git-receive-pack", ctx.request, { |
| 814 | 820 | env: hookEnv({ |
| 815 | socketPath: ctx.app.socketPath, | |
| 821 | socketPath: ctx.app.hookAddress.current, | |
| 822 | secret: ctx.app.hookSecret, | |
| 816 | 823 | repoId, |
| 817 | 824 | actorId: ctx.auth.user?.id ?? "", |
| 818 | 825 | }), |
| @@ -1379,8 +1386,16 @@ const fromStored = (stored: StoredResponse, replay: boolean): Response => | ||
| 1379 | 1386 | |
| 1380 | 1387 | // ---- the internal server (unix socket, hooks only) ---- |
| 1381 | 1388 | |
| 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 | ||
| 1382 | 1396 | 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 The secret gates this, so it is low. The body is still cast rather than checked, though: a |
||
| 1398 | const input: HookRequest = { ...parsed, gitEnv: forwardedGitEnv(parsed.gitEnv) }; | |
| 1384 | 1399 | const repo = getRepoById(app.db, input.repoId); |
| 1385 | 1400 | if (!repo) return new Response("unknown repository", { status: 400 }); |
| 1386 | 1401 | |
| @@ -1716,9 +1731,15 @@ export async function serve(app: App): Promise<Servers> { | ||
| 1716 | 1731 | }, |
| 1717 | 1732 | }); |
| 1718 | 1733 | |
| 1734 | const onPort = portInsteadOfSocket(); | |
| 1719 | 1735 | const internal = Bun.serve({ |
| 1720 | unix: app.socketPath, | |
| 1736 | ...(onPort ? { hostname: "127.0.0.1", port: 0 } : { unix: app.socketPath }), | |
| 1721 | 1737 | 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 | } | |
| 1722 | 1743 | const url = new URL(request.url); |
| 1723 | 1744 | if (request.method === "POST" && url.pathname === "/internal/hook") { |
| 1724 | 1745 | return handleHook(app, request); |
| @@ -1727,6 +1748,7 @@ export async function serve(app: App): Promise<Servers> { | ||
| 1727 | 1748 | }, |
| 1728 | 1749 | }); |
| 1729 | 1750 | |
| 1751 | if (onPort) app.hookAddress.current = tcpAddress(internal.port ?? 0); | |
| 1730 | 1752 | const port = web.port ?? 0; |
| 1731 | 1753 | return { |
| 1732 | 1754 | url: `http://localhost:${port}`, |
src/tui/ssh.ts +2 −1
| @@ -274,7 +274,8 @@ async function serveGitCommand( | ||
| 274 | 274 | const dir = repoDir(forge.reposRoot, repo.ownerName, repo.name); |
| 275 | 275 | const env: Record<string, string> = write |
| 276 | 276 | ? hookEnv({ |
| 277 | socketPath: forge.socketPath, | |
| 277 | socketPath: forge.hookAddress.current, | |
| 278 | secret: forge.hookSecret, | |
| 278 | 279 | repoId: repo.id, |
| 279 | 280 | actorId: user?.id ?? "", |
| 280 | 281 | }) |
test/accounts.test.ts +3 −2
| @@ -10,6 +10,7 @@ import { | ||
| 10 | 10 | type Notifier, |
| 11 | 11 | } from "../src/crew/notify.ts"; |
| 12 | 12 | import { queueAgentRun } from "../src/crew/dispatch.ts"; |
| 13 | import { fetchInternal } from "../src/git/hook-address.ts"; | |
| 13 | 14 | import { addComment, openIssue } from "../src/actions/issues.ts"; |
| 14 | 15 | import { createRepo } from "../src/actions/repos.ts"; |
| 15 | 16 | import { signUp } from "../src/actions/users.ts"; |
| @@ -343,9 +344,9 @@ describe("accounts: the owner sees, suspends and removes", () => { | ||
| 343 | 344 | // What git's pre-receive hook asks the forge once the pack is in: the |
| 344 | 345 | // ref update, from the account that pushed it. |
| 345 | 346 | const ask = () => |
| 346 | fetch("http://forge/internal/hook", { | |
| 347 | unix: app.socketPath, | |
| 347 | fetchInternal(app.hookAddress.current, "/internal/hook", { | |
| 348 | 348 | method: "POST", |
| 349 | headers: { "x-forge-hook-secret": app.hookSecret }, | |
| 349 | 350 | body: JSON.stringify({ |
| 350 | 351 | hook: "pre-receive", |
| 351 | 352 | repoId: made.value.id, |
test/apps-adversarial.test.ts +5 −1
| @@ -69,6 +69,9 @@ import { | ||
| 69 | 69 | } from "../src/runs/log.ts"; |
| 70 | 70 | import { grantedCookie } from "./helpers/identity.ts"; |
| 71 | 71 | import { gitClientEnv } from "./helpers/git-client.ts"; |
| 72 | import { portInsteadOfSocket } from "../src/git/hook-address.ts"; | |
| 73 | ||
| 74 | const testBySocket = portInsteadOfSocket() ? test.skip : test; | |
| 72 | 75 | |
| 73 | 76 | const DOMAIN = "apps.test"; |
| 74 | 77 | |
| @@ -634,7 +637,8 @@ describe("ensureEnvData path safety", () => { | ||
| 634 | 637 | ); |
| 635 | 638 | }); |
| 636 | 639 | |
| 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 () => { | |
| 638 | 642 | // An app that keeps a socket in its data — a forge does — must not |
| 639 | 643 | // make a preview impossible, let alone take the forge down with it. |
| 640 | 644 | const source = join(dataDir, "with-socket"); |
test/contain.test.ts +9 −3
| @@ -47,6 +47,11 @@ import type { Repo } from "../src/core/repos.ts"; | ||
| 47 | 47 | import { serve, type Servers } from "../src/http/server.ts"; |
| 48 | 48 | import { readRunLog } from "../src/runs/log.ts"; |
| 49 | 49 | 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; | |
| 50 | 55 | |
| 51 | 56 | describe("egress: what a cell may reach", () => { |
| 52 | 57 | 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", () => { | ||
| 145 | 150 | }); |
| 146 | 151 | }); |
| 147 | 152 | |
| 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", () => { | |
| 149 | 154 | let dir: string; |
| 150 | 155 | let upstream: ReturnType<typeof Bun.serve>; |
| 151 | 156 | let socket: string; |
| @@ -253,7 +258,7 @@ describe("egress: the proxy forwards what is allowed, byte for byte, and refuses | ||
| 253 | 258 | }); |
| 254 | 259 | }); |
| 255 | 260 | |
| 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", () => { | |
| 257 | 262 | let dir: string; |
| 258 | 263 | let socket: string; |
| 259 | 264 | let egress: { close(): void }; |
| @@ -473,7 +478,8 @@ describe("cells hide every mount that may carry a secret", () => { | ||
| 473 | 478 | const noCells = |
| 474 | 479 | process.env["FORGE_CELLS"] === "off" || |
| 475 | 480 | missing("bwrap", "socat") || |
| 476 | process.platform !== "linux"; | |
| 481 | process.platform !== "linux" || | |
| 482 | portInsteadOfSocket(); | |
| 477 | 483 | |
| 478 | 484 | /** What the probe tries, from inside a deploy or a check. */ |
| 479 | 485 | function probeSource(targets: { |
test/crew-sandbox.test.ts +196
| @@ -20,6 +20,18 @@ import { createRepo } from "../src/actions/repos.ts"; | ||
| 20 | 20 | import { createRun, getRun, noteRunProgress } from "../src/core/runs.ts"; |
| 21 | 21 | import { createUser } from "../src/core/users.ts"; |
| 22 | 22 | 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"; | |
| 23 | 35 | import { claudeSettings } from "../src/crew/claude.ts"; |
| 24 | 36 | import { |
| 25 | 37 | dataInTemp, |
| @@ -225,3 +237,187 @@ describe("the shell sandbox denies the whole data directory but the agent's own | ||
| 225 | 237 | expect(denied(join(dataDir, "crew/quill"), policy().denyRead)).toBe(true); |
| 226 | 238 | }); |
| 227 | 239 | }); |
| 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 |
||
| 242 | test("the settings keep the boundary: no unix socket is let through", () => { | |
| 243 | const settings = claudeSettings( | |
| 244 | sandboxPolicy({ | |
outdated
A 404 on |
||
| 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"); | |
|
||
| 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 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 8 | import { portInsteadOfSocket } from "../src/git/hook-address.ts"; | |
| 8 | 9 | import { lstat, mkdtemp, rm } from "node:fs/promises"; |
| 9 | 10 | import { tmpdir } from "node:os"; |
| 10 | 11 | import { join } from "node:path"; |
| @@ -82,7 +83,11 @@ describe("reconcile refreshes hooks (H-1)", () => { | ||
| 82 | 83 | }); |
| 83 | 84 | }); |
| 84 | 85 | |
| 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", () => { | |
| 86 | 91 | test("a socket another forge still answers on is left alone, and this forge boots beside it", async () => { |
| 87 | 92 | const dataDir = await mkdtemp(join(tmpdir(), "forge-harden-share-")); |
| 88 | 93 | const app = createApp({ dataDir, port: 0 }); |
| @@ -142,7 +147,7 @@ describe("two forges on one data directory", () => { | ||
| 142 | 147 | }); |
| 143 | 148 | }); |
| 144 | 149 | |
| 145 | describe("stale socket recovery (H-2)", () => { | |
| 150 | bySocket("stale socket recovery (H-2)", () => { | |
| 146 | 151 | test("a real SIGKILL-orphaned socket inode does not block boot", async () => { |
| 147 | 152 | const dataDir = await mkdtemp(join(tmpdir(), "forge-harden-sock-")); |
| 148 | 153 | const app = createApp({ dataDir, port: 0 }); |
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.