Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

410 lines · 19.1 KBRaw History
1import { accessSync, constants, copyFileSync, existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
2import { spawn, spawnSync } from "node:child_process";
3import { createServer, request as httpRequest, type Server } from "node:http";
4import net from "node:net";
5import os from "node:os";
6import path from "node:path";
7
8/**
9 * The jail an agent task runs in when it runs in someone's web terminal (phase 4b; Security's design).
10 *
11 * The terminal's sandbox holds the person's own Claude or ChatGPT sign-in, so the agent, which content in the
12 * repository can steer, runs under bubblewrap: no network of its own (a new network namespace with only
13 * loopback), a new PID namespace, no capabilities, an empty $HOME except the CLI's own sign-in, and nothing of
14 * the sandbox's work directory but the task's own clone. The person's checkout, its .git (with the sandbox's
15 * git credential) and the runner's state are not there; the checkout's objects are, read-only, because the
16 * task's clone borrows them (`git clone --shared`).
17 *
18 * The jail's only way out is a CONNECT proxy, outside it, that opens tunnels to the hosts in JAIL_HOSTS and
19 * nothing else. It never terminates TLS and handles no tokens: the CLI signs itself in, as in the terminal.
20 * Several of those hosts sit behind CDNs that serve every site they host on the same addresses, so a tunnel
21 * opens only once the client's TLS ClientHello names the same host (see `clientHelloServerName`).
22 * Inside, a relay on 127.0.0.1 forwards to the proxy's Unix socket and HTTPS_PROXY points at it.
23 *
24 * `finish` jails have no way out at all: they commit what the agent left and write a git bundle, which the
25 * runner, outside, pushes with the sandbox's credential.
26 *
27 * agent.mjs --jail --root /workspace agent --dir /workspace/task-1-1 --out /workspace/task-1-1.out --cli claude-code -- claude -p ...
28 */
29
30/** Where a terminal task may connect, by name (port 443, or 80 for package mirrors' plain HTTP). */
31export const JAIL_HOSTS = [
32 // Claude Code: the API, and its sign-in's token refresh.
33 "api.anthropic.com",
34 "console.anthropic.com",
35 "platform.claude.com",
36 // Codex on a ChatGPT plan: its backend and token refresh.
37 "chatgpt.com",
38 "auth.openai.com",
39 // Package registries, download hosts where they have their own (uploads go to other hosts).
40 "registry.npmjs.org",
41 "registry.yarnpkg.com",
42 "pypi.org",
43 "files.pythonhosted.org",
44 "index.crates.io",
45 "static.crates.io",
46 "rubygems.org",
47 "index.rubygems.org",
48 "repo1.maven.org",
49 "repo.maven.apache.org",
50 "deb.debian.org",
51 "security.debian.org",
52 "archive.ubuntu.com",
53 "security.ubuntu.com",
54];
55
56/** Hosts the plain-HTTP side of the proxy serves (apt's default sources), for GET and HEAD only. */
57const PLAIN_HTTP_HOSTS = new Set(["deb.debian.org", "security.debian.org", "archive.ubuntu.com", "security.ubuntu.com"]);
58
59export function jailAllows(host: string, port: number): boolean {
60 const h = host.toLowerCase().replace(/\.$/, "");
61 return (port === 443 && JAIL_HOSTS.includes(h)) || (port === 80 && PLAIN_HTTP_HOSTS.has(h));
62}
63
64/** Exit code when the jail itself can't start: nothing ran. */
65export const JAIL_FAILED = 125;
66
67const RELAY_PORT = 3128;
68
69/** Runs inside the jail: listens on 127.0.0.1 for the proxy, then runs the command and exits with it. */
70const RELAY = String.raw`
71import net from "node:net";
72import os from "node:os";
73import { spawn } from "node:child_process";
74const [sock, port, ...cmd] = process.argv.slice(2);
75const server = net.createServer((c) => {
76 const u = net.connect(sock);
77 c.pipe(u);
78 u.pipe(c);
79 c.on("error", () => u.destroy());
80 u.on("error", () => c.destroy());
81});
82server.on("error", (e) => {
83 process.stderr.write("diffinery: the jail's proxy relay failed: " + e.message + "\n");
84 process.exit(${JAIL_FAILED});
85});
86server.listen(Number(port), "127.0.0.1", () => {
87 const child = spawn(cmd[0], cmd.slice(1), { stdio: "inherit" });
88 child.on("error", (e) => {
89 process.stderr.write("diffinery: " + cmd[0] + ": " + e.message + "\n");
90 process.exit(127);
91 });
92 child.on("exit", (code, signal) => process.exit(code ?? 128 + (os.constants.signals[signal] ?? 1)));
93});
94`;
95
96export interface JailArgs {
97 mode: "agent" | "finish";
98 root: string;
99 dir: string;
100 out: string;
101 cli?: "claude-code" | "codex";
102 cmd: string[];
103}
104
105export function parseJailArgs(argv: string[], cwd = process.cwd()): JailArgs {
106 const sep = argv.indexOf("--");
107 if (sep < 0 || sep === argv.length - 1) throw new Error("usage: --jail --root <dir> agent|finish --dir <dir> --out <dir> [--cli claude-code|codex] -- <command...>");
108 const opts = argv.slice(0, sep);
109 const cmd = argv.slice(sep + 1);
110 const get = (name: string) => {
111 const i = opts.indexOf(name);
112 return i >= 0 ? opts[i + 1] : undefined;
113 };
114 const mode = opts.find((o) => o === "agent" || o === "finish") as JailArgs["mode"] | undefined;
115 const root = get("--root");
116 const dir = get("--dir");
117 const out = get("--out");
118 const cli = get("--cli");
119 if (!mode || !root || !dir || !out) throw new Error("--jail needs agent or finish, --root, --dir and --out");
120 if (cli !== undefined && cli !== "claude-code" && cli !== "codex") throw new Error(`unknown --cli ${cli}`);
121 const abs = (p: string) => path.resolve(cwd, p);
122 const a: JailArgs = { mode, root: abs(root), dir: abs(dir), out: abs(out), cli, cmd };
123 // The task's own directories: inside the work root, beside the person's checkout and never in it.
124 const repo = path.join(a.root, "repo");
125 for (const p of [a.dir, a.out]) {
126 if (path.dirname(p) !== a.root || p === repo || path.basename(p).startsWith(".")) throw new Error(`${p} isn't a task directory of ${a.root}`);
127 }
128 if (a.dir === a.out) throw new Error("--dir and --out must differ");
129 return a;
130}
131
132const under = (p: string, root: string) => p === root || p.startsWith(root.endsWith("/") ? root : `${root}/`);
133
134function which(name: string, PATH: string): string | null {
135 if (name.includes("/")) return existsSync(name) ? name : null;
136 for (const d of PATH.split(":").filter(Boolean)) {
137 const p = path.join(d, name);
138 try {
139 accessSync(p, constants.X_OK);
140 if (statSync(p).isFile()) return p;
141 } catch {
142 // not here
143 }
144 }
145 return null;
146}
147
148function findBwrap(): string | null {
149 return which("bwrap", process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin");
150}
151
152/** The files of its home directory each CLI gets in the jail, as copies (paths relative to $HOME). */
153export const CLI_FILES: Record<"claude-code" | "codex", string[]> = {
154 "claude-code": [".claude/.credentials.json", ".claude.json"],
155 codex: [".codex/auth.json", ".codex/config.toml"],
156};
157
158/** Variables the jail keeps from the sandbox's environment; everything else is cleared. */
159const KEEP_ENV = ["LANG", "LC_ALL", "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", "NODE_EXTRA_CA_CERTS", "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE", "PIP_BREAK_SYSTEM_PACKAGES", "COREPACK_ENABLE_DOWNLOAD_PROMPT", "DIFFINERY_TASK_MESSAGE", "DIFFINERY_TASK_START"];
160
161/** The bubblewrap command line for a jail (exported for tests). */
162export function bwrapArgs(a: JailArgs, o: { home: string; tools: string; env: NodeJS.ProcessEnv; cliPath?: string | null; nodePath: string }): string[] {
163 // The home directory (shell history, other sign-ins, other checkouts), the work root (the person's checkout and
164 // the runner's state) and /tmp are empty in the jail; /root too, where it isn't the home directory.
165 const hidden = [...new Set(["/tmp", o.home, a.root, ...(existsSync("/root") ? ["/root"] : [])])].sort((x, y) => x.length - y.length);
166 const args = ["--unshare-all", "--die-with-parent", "--new-session", "--cap-drop", "ALL", "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc"];
167 for (const h of hidden) args.push("--tmpfs", h);
168 const isHidden = (p: string) => hidden.some((h) => under(p, h));
169 // What the jail does see: the task's clone and its output directory, the checkout's objects read-only.
170 args.push("--bind", a.dir, a.dir, "--bind", a.out, a.out, "--ro-bind", path.join(a.root, "repo", ".git", "objects"), path.join(a.root, "repo", ".git", "objects"));
171 // The relay, the proxy's socket and the CLI's link: read-only (connecting to a socket needs no write).
172 args.push("--ro-bind", o.tools, o.tools);
173 const reveal = (p: string) => {
174 if (isHidden(p)) args.push("--ro-bind", p, p);
175 };
176 // Node (for the relay) and the CLI, wherever they are installed.
177 reveal(path.dirname(path.dirname(o.nodePath)));
178 if (o.cliPath) reveal(path.dirname(o.cliPath));
179 // CA bundles the sandbox trusts (the egress CA under web-only egress), wherever they are.
180 for (const k of ["NODE_EXTRA_CA_CERTS", "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE"]) {
181 const f = o.env[k];
182 if (f && path.isAbsolute(f) && existsSync(f)) reveal(f);
183 }
184 // The CLI's own sign-in and settings, at their usual paths so it signs itself in: private copies, never
185 // written back, so nothing the agent writes there (another account's credentials) reaches the terminal.
186 if (a.mode === "agent" && a.cli) {
187 for (const f of CLI_FILES[a.cli]) {
188 const copy = path.join(o.tools, "home", f.replace(/\//g, "_"));
189 if (!existsSync(copy)) continue;
190 args.push("--dir", path.dirname(path.join(o.home, f)), "--bind", copy, path.join(o.home, f));
191 }
192 }
193 const PATH = [path.join(o.tools, "bin"), ...(o.env.PATH ?? "/usr/local/bin:/usr/bin:/bin").split(":").filter((d) => d && !isHidden(d))].join(":");
194 args.push("--chdir", a.dir, "--clearenv", "--setenv", "PATH", PATH, "--setenv", "HOME", o.home, "--setenv", "TMPDIR", "/tmp", "--setenv", "DIFFINERY_TASK_OUT", a.out);
195 for (const k of KEEP_ENV) if (o.env[k] !== undefined) args.push("--setenv", k, o.env[k]!);
196 if (a.mode === "agent") {
197 // Claude Code skips its permission prompts when told it's in a sandbox (it runs as root here).
198 args.push("--setenv", "IS_SANDBOX", "1", "--setenv", "DISABLE_AUTOUPDATER", "1", "--setenv", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1");
199 const proxy = `http://127.0.0.1:${RELAY_PORT}`;
200 for (const k of ["HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy"]) args.push("--setenv", k, proxy);
201 }
202 return args;
203}
204
205const CLIENT_HELLO_TIMEOUT_MS = 10_000;
206/** A ClientHello spread over more than this is refused. */
207const MAX_CLIENT_HELLO_BYTES = 64 * 1024;
208
209/**
210 * The server name a TLS client's first bytes ask for (SNI): the name, lower case; `null` when they aren't a
211 * ClientHello, name no single host name, or carry Encrypted Client Hello (whose real name is hidden); or
212 * `undefined` while more bytes are needed. Handles a ClientHello split over several records.
213 */
214export function clientHelloServerName(data: Uint8Array): string | null | undefined {
215 const fragments: Uint8Array[] = [];
216 let have = 0;
217 let pos = 0;
218 for (;;) {
219 if (data.length < pos + 5) return data.length > MAX_CLIENT_HELLO_BYTES ? null : undefined;
220 if (data[pos] !== 0x16) return null; // not a handshake record
221 const len = (data[pos + 3]! << 8) | data[pos + 4]!;
222 if (len === 0 || len > 16384) return null;
223 if (data.length < pos + 5 + len) return pos + 5 + len > MAX_CLIENT_HELLO_BYTES ? null : undefined;
224 fragments.push(data.subarray(pos + 5, pos + 5 + len));
225 have += len;
226 pos += 5 + len;
227 const hs = Buffer.concat(fragments, have);
228 if (hs[0] !== 0x01) return null; // not a ClientHello
229 if (hs.length < 4) continue;
230 const size = (hs[1]! << 16) | (hs[2]! << 8) | hs[3]!;
231 if (size + 4 > MAX_CLIENT_HELLO_BYTES) return null;
232 if (hs.length < size + 4) continue;
233 try {
234 return serverNameOf(hs.subarray(4, 4 + size));
235 } catch {
236 return null;
237 }
238 }
239}
240
241function serverNameOf(b: Buffer): string | null {
242 const at = (n: number) => {
243 if (n >= b.length) throw new RangeError("short ClientHello");
244 return b[n]!;
245 };
246 const u16 = (n: number) => (at(n) << 8) | at(n + 1);
247 let p = 2 + 32; // version, random
248 p += 1 + at(p); // session id
249 p += 2 + u16(p); // cipher suites
250 p += 1 + at(p); // compression methods
251 if (p + 2 > b.length) return null; // no extensions, so no server name
252 const end = p + 2 + u16(p);
253 if (end > b.length) return null;
254 p += 2;
255 const names: string[] = [];
256 while (p < end) {
257 const type = u16(p);
258 const len = u16(p + 2);
259 const body = p + 4;
260 if (body + len > end) return null;
261 if (type === 0xfe0d) return null; // encrypted_client_hello
262 if (type === 0x0000) {
263 const listEnd = body + 2 + u16(body);
264 if (listEnd > body + len) return null;
265 for (let q = body + 2; q < listEnd; ) {
266 const nameType = at(q);
267 const nameLen = u16(q + 1);
268 if (q + 3 + nameLen > listEnd) return null;
269 if (nameType === 0) names.push(b.subarray(q + 3, q + 3 + nameLen).toString("latin1"));
270 q += 3 + nameLen;
271 }
272 }
273 p = body + len;
274 }
275 if (names.length !== 1 || !/^[A-Za-z0-9.-]+$/.test(names[0]!)) return null;
276 return names[0]!.toLowerCase().replace(/\.$/, "");
277}
278
279/** The CONNECT proxy outside the jail: tunnels to JAIL_HOSTS by name, plain-HTTP downloads from package mirrors. */
280export function startJailProxy(
281 listen: string | number,
282 log: (line: string) => void = (l) => process.stderr.write(`${l}\n`),
283 connectUpstream: (port: number, host: string, onConnect: () => void) => net.Socket = (port, host, onConnect) => net.connect(port, host, onConnect),
284): Promise<Server> {
285 const server = createServer((req, res) => {
286 let url: URL;
287 try {
288 url = new URL(req.url ?? "");
289 } catch {
290 res.writeHead(400).end();
291 return;
292 }
293 const port = Number(url.port || 80);
294 if (url.protocol !== "http:" || !jailAllows(url.hostname, port) || (req.method !== "GET" && req.method !== "HEAD")) {
295 log(`diffinery: refused ${req.method} ${url.protocol}//${url.host} from the task (only the hosts in /docs/agents/terminal#what-a-task-can-reach)`);
296 res.writeHead(403, { "content-type": "text/plain" }).end(`${url.host} isn't reachable from a terminal task.\n`);
297 return;
298 }
299 const headers = { ...req.headers };
300 delete headers["proxy-connection"];
301 delete headers["proxy-authorization"];
302 const up = httpRequest({ host: url.hostname, port, path: `${url.pathname}${url.search}`, method: req.method, headers }, (r) => {
303 res.writeHead(r.statusCode ?? 502, r.headers);
304 r.pipe(res);
305 });
306 up.on("error", () => res.writeHead(502).end());
307 up.end();
308 });
309 server.on("connect", (req, socket, head) => {
310 const m = /^\[?([A-Za-z0-9.:-]+?)\]?:(\d+)$/.exec(req.url ?? "");
311 const host = m?.[1] ?? "";
312 const port = Number(m?.[2] ?? 0);
313 socket.on("error", () => socket.destroy());
314 if (!m || port !== 443 || !jailAllows(host, port) || net.isIP(host)) {
315 log(`diffinery: refused a connection to ${req.url} from the task (only the hosts in /docs/agents/terminal#what-a-task-can-reach)`);
316 socket.end("HTTP/1.1 403 Forbidden\r\ncontent-type: text/plain\r\n\r\nnot reachable from a terminal task\n");
317 return;
318 }
319 // The tunnel opens only once the client's ClientHello asks for this same host: CONNECT to an allowed
320 // host behind a CDN, then TLS naming another site on that CDN, would otherwise reach that site.
321 socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
322 const want = host.toLowerCase().replace(/\.$/, "");
323 let buf = Buffer.from(head);
324 const refuse = (why: string) => {
325 log(`diffinery: refused a connection to ${req.url} from the task: ${why}`);
326 socket.destroy();
327 };
328 const timer = setTimeout(() => refuse("no TLS ClientHello"), CLIENT_HELLO_TIMEOUT_MS);
329 const check = () => {
330 const name = clientHelloServerName(buf);
331 if (name === undefined) return;
332 socket.off("data", onData);
333 socket.pause();
334 clearTimeout(timer);
335 if (name !== want) return refuse(name === null ? "its TLS ClientHello names no single server, or hides it (Encrypted Client Hello)" : `its TLS ClientHello asks for ${name}`);
336 const up = connectUpstream(port, host, () => {
337 up.write(buf);
338 up.pipe(socket);
339 socket.pipe(up);
340 socket.resume();
341 });
342 up.on("error", () => socket.destroy());
343 };
344 const onData = (d: Buffer) => {
345 buf = Buffer.concat([buf, d]);
346 check();
347 };
348 socket.on("data", onData);
349 socket.on("close", () => clearTimeout(timer));
350 if (buf.length) check();
351 });
352 return new Promise((resolve, reject) => {
353 server.once("error", reject);
354 server.listen(listen, () => resolve(server));
355 });
356}
357
358/** `agent.mjs --jail ...`: runs the command in the jail and exits with its code (JAIL_FAILED if it couldn't). */
359export async function runJail(argv: string[]): Promise<number> {
360 const fail = (m: string) => {
361 process.stderr.write(`diffinery: the task's jail couldn't start: ${m}\n`);
362 return JAIL_FAILED;
363 };
364 let a: JailArgs;
365 try {
366 a = parseJailArgs(argv);
367 } catch (e) {
368 return fail((e as Error).message);
369 }
370 const bwrap = findBwrap();
371 if (!bwrap) return fail("bubblewrap (bwrap) isn't installed in this sandbox");
372 for (const p of [a.dir, a.out]) if (!existsSync(p)) return fail(`${p} doesn't exist`);
373 // bwrap's own setup errors exit 1 like any command would: try the namespaces first, so a jail that can't
374 // exist is never mistaken for an agent that failed.
375 const probe = spawnSync(bwrap, ["--unshare-all", "--die-with-parent", "--cap-drop", "ALL", "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp", "--", "true"], { encoding: "utf8" });
376 if (probe.status !== 0) return fail(`bubblewrap can't create its namespaces here: ${(probe.stderr || probe.error?.message || `exit ${probe.status}`).trim()}`);
377 const home = os.homedir();
378 const tools = mkdtempSync(path.join(a.root, ".jail-"));
379 let server: Server | null = null;
380 try {
381 // The CLI, found as the terminal finds it, linked into the jail's PATH.
382 const cliPath = which(a.cmd[0]!, process.env.PATH ?? "");
383 const real = cliPath ? realpathSync(cliPath) : null;
384 const bin = path.join(tools, "bin");
385 mkdirSync(bin);
386 if (real) symlinkSync(real, path.join(bin, path.basename(a.cmd[0]!)));
387 mkdirSync(path.join(tools, "home"));
388 if (a.mode === "agent" && a.cli) for (const f of CLI_FILES[a.cli]) if (existsSync(path.join(home, f))) copyFileSync(path.join(home, f), path.join(tools, "home", f.replace(/\//g, "_")));
389 let cmd = a.cmd;
390 if (a.mode === "agent") {
391 const sock = path.join(tools, "proxy.sock");
392 server = await startJailProxy(sock);
393 writeFileSync(path.join(tools, "relay.mjs"), RELAY);
394 cmd = [process.execPath, path.join(tools, "relay.mjs"), sock, String(RELAY_PORT), ...a.cmd];
395 }
396 const args = [...bwrapArgs(a, { home, tools, env: process.env, cliPath: real, nodePath: process.execPath }), "--", ...cmd];
397 const code = await new Promise<number>((resolve) => {
398 const child = spawn(bwrap, args, { stdio: "inherit" });
399 const stop = (sig: NodeJS.Signals) => child.kill(sig);
400 process.on("SIGTERM", stop);
401 process.on("SIGINT", stop);
402 child.on("error", (e) => resolve(fail(e.message)));
403 child.on("exit", (c, sig) => resolve(c ?? 128 + (os.constants.signals[sig as NodeJS.Signals] ?? 1)));
404 });
405 return code;
406 } finally {
407 server?.close();
408 rmSync(tools, { recursive: true, force: true });
409 }
410}