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.

305 lines · 14.9 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 * Inside, a relay on 127.0.0.1 forwards to the proxy's Unix socket and HTTPS_PROXY points at it.
21 *
22 * `finish` jails have no way out at all: they commit what the agent left and write a git bundle, which the
23 * runner, outside, pushes with the sandbox's credential.
24 *
25 * agent.mjs --jail --root /workspace agent --dir /workspace/task-1-1 --out /workspace/task-1-1.out --cli claude-code -- claude -p ...
26 */
27
28/** Where a terminal task may connect, by name (port 443, or 80 for package mirrors' plain HTTP). */
29export const JAIL_HOSTS = [
30 // Claude Code: the API, and its sign-in's token refresh.
31 "api.anthropic.com",
32 "console.anthropic.com",
33 "platform.claude.com",
34 // Codex on a ChatGPT plan: its backend and token refresh.
35 "chatgpt.com",
36 "auth.openai.com",
37 // Package registries, download hosts where they have their own (uploads go to other hosts).
38 "registry.npmjs.org",
39 "registry.yarnpkg.com",
40 "pypi.org",
41 "files.pythonhosted.org",
42 "index.crates.io",
43 "static.crates.io",
44 "rubygems.org",
45 "index.rubygems.org",
46 "repo1.maven.org",
47 "repo.maven.apache.org",
48 "deb.debian.org",
49 "security.debian.org",
50 "archive.ubuntu.com",
51 "security.ubuntu.com",
52];
53
54/** Hosts the plain-HTTP side of the proxy serves (apt's default sources), for GET and HEAD only. */
55const PLAIN_HTTP_HOSTS = new Set(["deb.debian.org", "security.debian.org", "archive.ubuntu.com", "security.ubuntu.com"]);
56
57export function jailAllows(host: string, port: number): boolean {
58 const h = host.toLowerCase().replace(/\.$/, "");
59 return (port === 443 && JAIL_HOSTS.includes(h)) || (port === 80 && PLAIN_HTTP_HOSTS.has(h));
60}
61
62/** Exit code when the jail itself can't start: nothing ran. */
63export const JAIL_FAILED = 125;
64
65const RELAY_PORT = 3128;
66
67/** Runs inside the jail: listens on 127.0.0.1 for the proxy, then runs the command and exits with it. */
68const RELAY = String.raw`
69import net from "node:net";
70import os from "node:os";
71import { spawn } from "node:child_process";
72const [sock, port, ...cmd] = process.argv.slice(2);
73const server = net.createServer((c) => {
74 const u = net.connect(sock);
75 c.pipe(u);
76 u.pipe(c);
77 c.on("error", () => u.destroy());
78 u.on("error", () => c.destroy());
79});
80server.on("error", (e) => {
81 process.stderr.write("diffinery: the jail's proxy relay failed: " + e.message + "\n");
82 process.exit(${JAIL_FAILED});
83});
84server.listen(Number(port), "127.0.0.1", () => {
85 const child = spawn(cmd[0], cmd.slice(1), { stdio: "inherit" });
86 child.on("error", (e) => {
87 process.stderr.write("diffinery: " + cmd[0] + ": " + e.message + "\n");
88 process.exit(127);
89 });
90 child.on("exit", (code, signal) => process.exit(code ?? 128 + (os.constants.signals[signal] ?? 1)));
91});
92`;
93
94export interface JailArgs {
95 mode: "agent" | "finish";
96 root: string;
97 dir: string;
98 out: string;
99 cli?: "claude-code" | "codex";
100 cmd: string[];
101}
102
103export function parseJailArgs(argv: string[], cwd = process.cwd()): JailArgs {
104 const sep = argv.indexOf("--");
105 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...>");
106 const opts = argv.slice(0, sep);
107 const cmd = argv.slice(sep + 1);
108 const get = (name: string) => {
109 const i = opts.indexOf(name);
110 return i >= 0 ? opts[i + 1] : undefined;
111 };
112 const mode = opts.find((o) => o === "agent" || o === "finish") as JailArgs["mode"] | undefined;
113 const root = get("--root");
114 const dir = get("--dir");
115 const out = get("--out");
116 const cli = get("--cli");
117 if (!mode || !root || !dir || !out) throw new Error("--jail needs agent or finish, --root, --dir and --out");
118 if (cli !== undefined && cli !== "claude-code" && cli !== "codex") throw new Error(`unknown --cli ${cli}`);
119 const abs = (p: string) => path.resolve(cwd, p);
120 const a: JailArgs = { mode, root: abs(root), dir: abs(dir), out: abs(out), cli, cmd };
121 // The task's own directories: inside the work root, beside the person's checkout and never in it.
122 const repo = path.join(a.root, "repo");
123 for (const p of [a.dir, a.out]) {
124 if (path.dirname(p) !== a.root || p === repo || path.basename(p).startsWith(".")) throw new Error(`${p} isn't a task directory of ${a.root}`);
125 }
126 if (a.dir === a.out) throw new Error("--dir and --out must differ");
127 return a;
128}
129
130const under = (p: string, root: string) => p === root || p.startsWith(root.endsWith("/") ? root : `${root}/`);
131
132function which(name: string, PATH: string): string | null {
133 if (name.includes("/")) return existsSync(name) ? name : null;
134 for (const d of PATH.split(":").filter(Boolean)) {
135 const p = path.join(d, name);
136 try {
137 accessSync(p, constants.X_OK);
138 if (statSync(p).isFile()) return p;
139 } catch {
140 // not here
141 }
142 }
143 return null;
144}
145
146function findBwrap(): string | null {
147 return which("bwrap", process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin");
148}
149
150/** The files of its home directory each CLI gets in the jail, as copies (paths relative to $HOME). */
151export const CLI_FILES: Record<"claude-code" | "codex", string[]> = {
152 "claude-code": [".claude/.credentials.json", ".claude.json"],
153 codex: [".codex/auth.json", ".codex/config.toml"],
154};
155
156/** Variables the jail keeps from the sandbox's environment; everything else is cleared. */
157const 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"];
158
159/** The bubblewrap command line for a jail (exported for tests). */
160export function bwrapArgs(a: JailArgs, o: { home: string; tools: string; env: NodeJS.ProcessEnv; cliPath?: string | null; nodePath: string }): string[] {
161 // The home directory (shell history, other sign-ins, other checkouts), the work root (the person's checkout and
162 // the runner's state) and /tmp are empty in the jail; /root too, where it isn't the home directory.
163 const hidden = [...new Set(["/tmp", o.home, a.root, ...(existsSync("/root") ? ["/root"] : [])])].sort((x, y) => x.length - y.length);
164 const args = ["--unshare-all", "--die-with-parent", "--new-session", "--cap-drop", "ALL", "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc"];
165 for (const h of hidden) args.push("--tmpfs", h);
166 const isHidden = (p: string) => hidden.some((h) => under(p, h));
167 // What the jail does see: the task's clone and its output directory, the checkout's objects read-only.
168 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"));
169 // The relay, the proxy's socket and the CLI's link: read-only (connecting to a socket needs no write).
170 args.push("--ro-bind", o.tools, o.tools);
171 const reveal = (p: string) => {
172 if (isHidden(p)) args.push("--ro-bind", p, p);
173 };
174 // Node (for the relay) and the CLI, wherever they are installed.
175 reveal(path.dirname(path.dirname(o.nodePath)));
176 if (o.cliPath) reveal(path.dirname(o.cliPath));
177 // CA bundles the sandbox trusts (the egress CA under web-only egress), wherever they are.
178 for (const k of ["NODE_EXTRA_CA_CERTS", "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE"]) {
179 const f = o.env[k];
180 if (f && path.isAbsolute(f) && existsSync(f)) reveal(f);
181 }
182 // The CLI's own sign-in and settings, at their usual paths so it signs itself in: private copies, never
183 // written back, so nothing the agent writes there (another account's credentials) reaches the terminal.
184 if (a.mode === "agent" && a.cli) {
185 for (const f of CLI_FILES[a.cli]) {
186 const copy = path.join(o.tools, "home", f.replace(/\//g, "_"));
187 if (!existsSync(copy)) continue;
188 args.push("--dir", path.dirname(path.join(o.home, f)), "--bind", copy, path.join(o.home, f));
189 }
190 }
191 const PATH = [path.join(o.tools, "bin"), ...(o.env.PATH ?? "/usr/local/bin:/usr/bin:/bin").split(":").filter((d) => d && !isHidden(d))].join(":");
192 args.push("--chdir", a.dir, "--clearenv", "--setenv", "PATH", PATH, "--setenv", "HOME", o.home, "--setenv", "TMPDIR", "/tmp", "--setenv", "DIFFINERY_TASK_OUT", a.out);
193 for (const k of KEEP_ENV) if (o.env[k] !== undefined) args.push("--setenv", k, o.env[k]!);
194 if (a.mode === "agent") {
195 // Claude Code skips its permission prompts when told it's in a sandbox (it runs as root here).
196 args.push("--setenv", "IS_SANDBOX", "1", "--setenv", "DISABLE_AUTOUPDATER", "1", "--setenv", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1");
197 const proxy = `http://127.0.0.1:${RELAY_PORT}`;
198 for (const k of ["HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy"]) args.push("--setenv", k, proxy);
199 }
200 return args;
201}
202
203/** The CONNECT proxy outside the jail: tunnels to JAIL_HOSTS by name, plain-HTTP downloads from package mirrors. */
204export function startJailProxy(listen: string | number, log: (line: string) => void = (l) => process.stderr.write(`${l}\n`)): Promise<Server> {
205 const server = createServer((req, res) => {
206 let url: URL;
207 try {
208 url = new URL(req.url ?? "");
209 } catch {
210 res.writeHead(400).end();
211 return;
212 }
213 const port = Number(url.port || 80);
214 if (url.protocol !== "http:" || !jailAllows(url.hostname, port) || (req.method !== "GET" && req.method !== "HEAD")) {
215 log(`diffinery: refused ${req.method} ${url.protocol}//${url.host} from the task (only the hosts in /docs/agents/terminal#what-a-task-can-reach)`);
216 res.writeHead(403, { "content-type": "text/plain" }).end(`${url.host} isn't reachable from a terminal task.\n`);
217 return;
218 }
219 const headers = { ...req.headers };
220 delete headers["proxy-connection"];
221 delete headers["proxy-authorization"];
222 const up = httpRequest({ host: url.hostname, port, path: `${url.pathname}${url.search}`, method: req.method, headers }, (r) => {
223 res.writeHead(r.statusCode ?? 502, r.headers);
224 r.pipe(res);
225 });
226 up.on("error", () => res.writeHead(502).end());
227 up.end();
228 });
229 server.on("connect", (req, socket, head) => {
230 const m = /^\[?([A-Za-z0-9.:-]+?)\]?:(\d+)$/.exec(req.url ?? "");
231 const host = m?.[1] ?? "";
232 const port = Number(m?.[2] ?? 0);
233 socket.on("error", () => socket.destroy());
234 if (!m || port !== 443 || !jailAllows(host, port) || net.isIP(host)) {
235 log(`diffinery: refused a connection to ${req.url} from the task (only the hosts in /docs/agents/terminal#what-a-task-can-reach)`);
236 socket.end("HTTP/1.1 403 Forbidden\r\ncontent-type: text/plain\r\n\r\nnot reachable from a terminal task\n");
237 return;
238 }
239 const up = net.connect(port, host, () => {
240 socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
241 if (head.length) up.write(head);
242 up.pipe(socket);
243 socket.pipe(up);
244 });
245 up.on("error", () => socket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"));
246 });
247 return new Promise((resolve, reject) => {
248 server.once("error", reject);
249 server.listen(listen, () => resolve(server));
250 });
251}
252
253/** `agent.mjs --jail ...`: runs the command in the jail and exits with its code (JAIL_FAILED if it couldn't). */
254export async function runJail(argv: string[]): Promise<number> {
255 const fail = (m: string) => {
256 process.stderr.write(`diffinery: the task's jail couldn't start: ${m}\n`);
257 return JAIL_FAILED;
258 };
259 let a: JailArgs;
260 try {
261 a = parseJailArgs(argv);
262 } catch (e) {
263 return fail((e as Error).message);
264 }
265 const bwrap = findBwrap();
266 if (!bwrap) return fail("bubblewrap (bwrap) isn't installed in this sandbox");
267 for (const p of [a.dir, a.out]) if (!existsSync(p)) return fail(`${p} doesn't exist`);
268 // bwrap's own setup errors exit 1 like any command would: try the namespaces first, so a jail that can't
269 // exist is never mistaken for an agent that failed.
270 const probe = spawnSync(bwrap, ["--unshare-all", "--die-with-parent", "--cap-drop", "ALL", "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp", "--", "true"], { encoding: "utf8" });
271 if (probe.status !== 0) return fail(`bubblewrap can't create its namespaces here: ${(probe.stderr || probe.error?.message || `exit ${probe.status}`).trim()}`);
272 const home = os.homedir();
273 const tools = mkdtempSync(path.join(a.root, ".jail-"));
274 let server: Server | null = null;
275 try {
276 // The CLI, found as the terminal finds it, linked into the jail's PATH.
277 const cliPath = which(a.cmd[0]!, process.env.PATH ?? "");
278 const real = cliPath ? realpathSync(cliPath) : null;
279 const bin = path.join(tools, "bin");
280 mkdirSync(bin);
281 if (real) symlinkSync(real, path.join(bin, path.basename(a.cmd[0]!)));
282 mkdirSync(path.join(tools, "home"));
283 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, "_")));
284 let cmd = a.cmd;
285 if (a.mode === "agent") {
286 const sock = path.join(tools, "proxy.sock");
287 server = await startJailProxy(sock);
288 writeFileSync(path.join(tools, "relay.mjs"), RELAY);
289 cmd = [process.execPath, path.join(tools, "relay.mjs"), sock, String(RELAY_PORT), ...a.cmd];
290 }
291 const args = [...bwrapArgs(a, { home, tools, env: process.env, cliPath: real, nodePath: process.execPath }), "--", ...cmd];
292 const code = await new Promise<number>((resolve) => {
293 const child = spawn(bwrap, args, { stdio: "inherit" });
294 const stop = (sig: NodeJS.Signals) => child.kill(sig);
295 process.on("SIGTERM", stop);
296 process.on("SIGINT", stop);
297 child.on("error", (e) => resolve(fail(e.message)));
298 child.on("exit", (c, sig) => resolve(c ?? 128 + (os.constants.signals[sig as NodeJS.Signals] ?? 1)));
299 });
300 return code;
301 } finally {
302 server?.close();
303 rmSync(tools, { recursive: true, force: true });
304 }
305}