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.

258 lines · 15.1 KBRaw History
1import { mkdirSync, writeFileSync } from "node:fs";
2import { createServer, request, type Server } from "node:http";
3import net from "node:net";
4import path from "node:path";
5import { afterAll, beforeAll, describe, expect, it } from "vitest";
6import { bwrapArgs, JAIL_HOSTS, jailAllows, MODEL_URL, parseJailArgs, REGISTRY_HOSTS, SHARED_ENV, startJailProxy, startModelProxy } from "../src/jail.js";
7import { tmp } from "./helpers.js";
8
9describe("a terminal task's jail", () => {
10 it("takes only task directories beside the checkout", () => {
11 const a = parseJailArgs(["--root", "/w", "agent", "--dir", "../task-1-1", "--out", "../task-1-1.out", "--cli", "claude-code", "--", "claude", "-p", "x"], "/w/repo");
12 expect(a).toEqual({ mode: "agent", root: "/w", dir: "/w/task-1-1", out: "/w/task-1-1.out", cli: "claude-code", egress: "cli", cmd: ["claude", "-p", "x"] });
13 const bad = (dir: string, out = "../o") => () => parseJailArgs(["--root", "/w", "agent", "--dir", dir, "--out", out, "--", "true"], "/w/repo");
14 expect(bad(".")).toThrow(/isn't a task directory/); // the person's checkout
15 expect(bad("../repo")).toThrow(/isn't a task directory/);
16 expect(bad("../.jail")).toThrow(/isn't a task directory/);
17 expect(bad("/etc")).toThrow(/isn't a task directory/);
18 expect(bad("../a/b")).toThrow(/isn't a task directory/);
19 expect(bad("../a", "../a")).toThrow(/must differ/);
20 expect(() => parseJailArgs(["--root", "/w", "agent", "--dir", "../a", "--out", "../b"])).toThrow(/usage/);
21 expect(() => parseJailArgs(["--root", "/w", "agent", "--dir", "../a", "--out", "../b", "--cli", "gemini", "--", "x"], "/w/repo")).toThrow(/unknown --cli/);
22 });
23
24 it("reaches the providers' sign-in and API hosts and download hosts of package registries, by name", () => {
25 expect(jailAllows("claude.ai", 443)).toBe(false);
26 for (const h of ["api.anthropic.com", "chatgpt.com", "auth.openai.com", "registry.npmjs.org", "files.pythonhosted.org", "index.crates.io", "API.Anthropic.com."]) expect(jailAllows(h, 443), h).toBe(true);
27 // Upload hosts, fetch-for-you proxies, arbitrary sites, and other ports are not.
28 for (const h of ["upload.pypi.org", "crates.io", "proxy.golang.org", "api.openai.com", "example.com", "github.com", "evil.anthropic.com.example.com"]) expect(jailAllows(h, 443), h).toBe(false);
29 expect(jailAllows("api.anthropic.com", 80)).toBe(false);
30 expect(jailAllows("api.anthropic.com", 22)).toBe(false);
31 expect(jailAllows("archive.ubuntu.com", 80)).toBe(true);
32 expect(jailAllows("registry.npmjs.org", 80)).toBe(false);
33 expect(JAIL_HOSTS).not.toContain("*");
34 });
35
36 it("hides the home directory, the work root and /tmp, and shows only the task's clone, its output, the checkout's objects and the CLI's sign-in", () => {
37 const a = parseJailArgs(["--root", "/w", "agent", "--dir", "../t", "--out", "../t.out", "--cli", "claude-code", "--", "claude"], "/w/repo");
38 const tools = tmp("jail-tools-");
39 mkdirSync(path.join(tools, "home"));
40 writeFileSync(path.join(tools, "home", ".claude_.credentials.json"), "{}");
41 const args = bwrapArgs(a, { home: "/home/p", tools, env: { PATH: "/home/p/bin:/usr/bin", NODE_EXTRA_CA_CERTS: "/etc/ca.crt", DIFFINERY_JOB_TOKEN: "secret", GIT_AUTHOR_NAME: "bot" }, cliPath: "/usr/lib/claude/cli.js", nodePath: "/usr/bin/node" });
42 const s = args.join(" ");
43 for (const f of ["--unshare-all", "--die-with-parent", "--new-session", "--clearenv"]) expect(args).toContain(f);
44 expect(s).toContain("--cap-drop ALL");
45 expect(args).not.toContain("--share-net");
46 for (const h of ["/tmp", "/home/p", "/w"]) expect(s).toContain(`--tmpfs ${h}`);
47 expect(s).toContain("--bind /w/t /w/t");
48 expect(s).toContain("--bind /w/t.out /w/t.out");
49 expect(s).toContain("--ro-bind /w/repo/.git/objects /w/repo/.git/objects");
50 expect(s).not.toMatch(/\/w\/repo(\/\.git)?(\/config)? /);
51 expect(s).not.toContain("--bind /w/repo/.git ");
52 // The environment is cleared, then given only what it needs: no sandbox secrets.
53 expect(s).not.toContain("secret");
54 expect(s).toContain("--setenv GIT_AUTHOR_NAME bot");
55 expect(s).toContain("--setenv HTTPS_PROXY http://127.0.0.1:3128");
56 expect(s).toContain(`--setenv PATH ${tools}/bin:/usr/bin`);
57 // The sign-in is a private copy, and the jail's tools are read-only.
58 expect(s).toContain(`--bind ${tools}/home/.claude_.credentials.json /home/p/.claude/.credentials.json`);
59 expect(s).not.toContain("--bind /home/p/.claude/.credentials.json");
60 expect(s).toContain(`--ro-bind ${tools} ${tools}`);
61 expect(s).not.toContain(".claude.json"); // not copied here, so not bound
62 expect(s).toContain("--chdir /w/t");
63 // A finish jail has no way out and no sign-in.
64 const f = bwrapArgs({ ...a, mode: "finish", cli: undefined }, { home: "/home/p", tools, env: {}, nodePath: "/usr/bin/node" }).join(" ");
65 expect(f).not.toContain("HTTPS_PROXY");
66 expect(f).not.toContain(".claude");
67 });
68
69 describe("its proxy", () => {
70 let dir: string;
71 let sock: string;
72 let close: () => void;
73 const logs: string[] = [];
74 beforeAll(async () => {
75 dir = tmp("jail-");
76 sock = path.join(dir, "p.sock");
77 const server = await startJailProxy(sock, (l) => logs.push(l));
78 close = () => server.close();
79 });
80 afterAll(() => close());
81
82 const connect = (target: string) =>
83 new Promise<string>((resolve) => {
84 const c = net.connect(sock, () => c.write(`CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\n\r\n`));
85 let got = "";
86 c.on("data", (d) => {
87 got += d;
88 if (got.includes("\r\n\r\n")) {
89 c.destroy();
90 resolve(got.split("\r\n")[0]!);
91 }
92 });
93 c.on("end", () => resolve(got.split("\r\n")[0]!));
94 c.on("error", () => resolve(got.split("\r\n")[0] ?? ""));
95 });
96
97 it("refuses tunnels anywhere else, and to the allowed hosts on other ports or by address", async () => {
98 expect(await connect("example.com:443")).toBe("HTTP/1.1 403 Forbidden");
99 expect(await connect("api.anthropic.com:80")).toBe("HTTP/1.1 403 Forbidden");
100 expect(await connect("1.1.1.1:443")).toBe("HTTP/1.1 403 Forbidden");
101 expect(await connect("api.anthropic.com.evil.test:443")).toBe("HTTP/1.1 403 Forbidden");
102 expect(logs.some((l) => l.includes("refused a connection to example.com:443"))).toBe(true);
103 });
104
105 it("serves plain HTTP only to package mirrors, and only downloads", async () => {
106 const send = (method: string, url: string) =>
107 new Promise<number>((resolve) => {
108 const r = request({ socketPath: sock, method, path: url, headers: { host: new URL(url).host } }, (res) => {
109 res.resume();
110 resolve(res.statusCode ?? 0);
111 });
112 r.on("error", () => resolve(0));
113 r.end();
114 });
115 expect(await send("GET", "http://example.com/")).toBe(403);
116 expect(await send("POST", "http://archive.ubuntu.com/ubuntu/")).toBe(403);
117 expect(await send("GET", "http://registry.npmjs.org/left-pad")).toBe(403);
118 });
119 });
120});
121
122describe("a headless run's jail (phase 5d)", () => {
123 const args = (...extra: string[]) => ["--root", "/w", "agent", "--dir", "../task-2-1", "--out", "../task-2-1.out", ...extra, "--", "claude", "-p", "x"];
124 it("takes --egress model, for agent jails without a sign-in", () => {
125 expect(parseJailArgs(args("--egress", "model"), "/w/repo").egress).toBe("model");
126 expect(() => parseJailArgs(args("--egress", "model", "--cli", "claude-code"), "/w/repo")).toThrow(/without --cli/);
127 expect(() => parseJailArgs(["--root", "/w", "finish", "--dir", "../a", "--out", "../b", "--egress", "model", "--", "true"], "/w/repo")).toThrow(/for agent jails/);
128 expect(() => parseJailArgs(args("--egress", "web"), "/w/repo")).toThrow(/unknown --egress/);
129 });
130
131 it("reaches the package registries only: no CLI vendor, and never Diffinery's host", () => {
132 for (const h of ["api.anthropic.com", "console.anthropic.com", "chatgpt.com", "auth.openai.com", "diffinery.example"]) expect(jailAllows(h, 443, "model"), h).toBe(false);
133 for (const h of REGISTRY_HOSTS) expect(jailAllows(h, 443, "model"), h).toBe(true);
134 expect(jailAllows("archive.ubuntu.com", 80, "model")).toBe(true);
135 });
136
137 it("keeps the model variables, never the run's token, and sends model requests around the CONNECT proxy", () => {
138 const a = parseJailArgs(args("--egress", "model"), "/w/repo");
139 const tools = tmp("jail-tools-");
140 const env = { PATH: "/usr/bin", ANTHROPIC_BASE_URL: `${MODEL_URL}/-/model/anthropic`, ANTHROPIC_API_KEY: "diffinery-jail", DIFFINERY_MODEL_TOKEN: "dmt_repo_secret", DIFFINERY_MODEL_ORIGIN: "https://d.example", OPENAI_API_KEY: "diffinery-jail" };
141 const s = bwrapArgs(a, { home: "/home/p", tools, env, nodePath: "/usr/bin/node" }).join(" ");
142 expect(s).toContain(`--setenv ANTHROPIC_BASE_URL ${MODEL_URL}/-/model/anthropic`);
143 expect(s).toContain("--setenv ANTHROPIC_API_KEY diffinery-jail");
144 expect(s).toContain("--setenv OPENAI_API_KEY diffinery-jail");
145 expect(s).not.toContain("dmt_repo_secret");
146 expect(s).not.toContain("d.example");
147 expect(s).toContain("--setenv NO_PROXY 127.0.0.1,localhost");
148 // A terminal task's jail keeps none of them.
149 const t = bwrapArgs({ ...a, egress: "cli", cli: "claude-code" }, { home: "/home/p", tools, env, nodePath: "/usr/bin/node" }).join(" ");
150 expect(t).not.toContain("ANTHROPIC_BASE_URL");
151 expect(t).not.toContain("NO_PROXY");
152 });
153
154 it("shares a test pool's directories, and refuses one that would undo what the jail hides", () => {
155 const e2e = tmp("jail-e2e-");
156 mkdirSync(path.join(e2e, "pool", "s1", "repo"), { recursive: true });
157 mkdirSync(path.join(e2e, "work"));
158 const root = path.join(e2e, "pool", "s1");
159 const home = path.join(e2e, "home");
160 mkdirSync(path.join(home, "sub"), { recursive: true });
161 const work = path.join(e2e, "work");
162 const a = parseJailArgs(["--root", root, "agent", "--dir", "../t", "--out", "../t.out", "--egress", "model", "--", "true"], path.join(root, "repo"));
163 const args = (shared?: string) => bwrapArgs(a, { home, tools: tmp("jail-tools-"), env: shared === undefined ? {} : { [SHARED_ENV]: shared }, nodePath: "/usr/bin/node" }).join(" ");
164 const s = args(work);
165 expect(s.indexOf(`--bind ${work} ${work} `)).toBeGreaterThan(s.indexOf("--tmpfs /tmp "));
166 expect(args()).not.toContain(`--bind ${work} `);
167 // `/`, the work root (with the checkout and its git credential), the home directory, /tmp, anything holding them
168 // or inside the work root or home: shared, they would undo the jail's isolation, so the jail doesn't start.
169 for (const bad of ["/", e2e, path.join(e2e, "pool"), root, path.join(root, "repo"), home, path.join(home, "sub"), "/tmp", `${work}:/`]) expect(() => args(bad), bad).toThrow(/is, holds or is inside a directory the jail hides/);
170 for (const bad of ["relative", "/nonexistent"]) expect(() => args(bad), bad).toThrow(/isn't an existing absolute directory/);
171 });
172
173 describe("its model proxy", () => {
174 let sock: string;
175 let upstream: Server;
176 let close: () => void;
177 const seen: Array<{ method: string; url: string; headers: Record<string, unknown>; body: string }> = [];
178 const logs: string[] = [];
179 let slowOpen = 0;
180 let slowClosed = 0;
181 const until = async (ok: () => boolean) => {
182 for (let i = 0; i < 100 && !ok(); i++) await new Promise((r) => setTimeout(r, 20));
183 expect(ok()).toBe(true);
184 };
185 beforeAll(async () => {
186 upstream = createServer((req, res) => {
187 // A slow answer: it streams until whoever asked goes away.
188 if (req.headers["content-length"] === "4") {
189 slowOpen++;
190 res.writeHead(200, { "content-type": "text/event-stream" });
191 res.write("data: thinking\n\n");
192 res.on("close", () => slowClosed++);
193 return;
194 }
195 let body = "";
196 req.on("data", (d) => (body += d));
197 req.on("end", () => {
198 seen.push({ method: req.method!, url: req.url!, headers: req.headers, body });
199 res.writeHead(200, { "content-type": "text/event-stream", "set-cookie": "s=1" });
200 res.write("data: one\n\n");
201 setTimeout(() => res.end("data: two\n\n"), 20);
202 });
203 });
204 await new Promise<void>((r) => upstream.listen(0, "127.0.0.1", r));
205 const origin = `http://127.0.0.1:${(upstream.address() as net.AddressInfo).port}`;
206 sock = path.join(tmp("jail-model-"), "m.sock");
207 const server = await startModelProxy(sock, origin, "dmt_repo_runtoken", (l) => logs.push(l));
208 close = () => {
209 server.close();
210 upstream.close();
211 };
212 });
213 afterAll(() => close());
214
215 const send = (method: string, url: string, headers: Record<string, string> = {}, body?: string) =>
216 new Promise<{ status: number; body: string; headers: Record<string, unknown> }>((resolve) => {
217 const r = request({ socketPath: sock, method, path: url, headers: { host: "127.0.0.1:3129", ...headers } }, (res) => {
218 let b = "";
219 res.on("data", (d) => (b += d));
220 res.on("end", () => resolve({ status: res.statusCode ?? 0, body: b, headers: res.headers }));
221 });
222 r.on("error", () => resolve({ status: 0, body: "", headers: {} }));
223 r.end(body);
224 });
225
226 it("forwards model requests with the run's token in place of whatever the agent sent, and streams the answer", async () => {
227 const r = await send("POST", "/-/model/anthropic/v1/messages?beta=true", { "x-api-key": "diffinery-jail", authorization: "Bearer planted-diffinery-token", cookie: "dfy_session=x", "content-type": "application/json", "anthropic-version": "2023-06-01" }, '{"max_tokens":5}');
228 expect(r.status).toBe(200);
229 expect(r.body).toBe("data: one\n\ndata: two\n\n");
230 expect(r.headers["set-cookie"]).toBeUndefined();
231 const got = seen.at(-1)!;
232 expect(got.url).toBe("/-/model/anthropic/v1/messages?beta=true");
233 expect(got.body).toBe('{"max_tokens":5}');
234 expect(got.headers.authorization).toBe("Bearer dmt_repo_runtoken");
235 expect(got.headers["x-api-key"]).toBeUndefined();
236 expect(got.headers.cookie).toBeUndefined();
237 expect(got.headers["anthropic-version"]).toBe("2023-06-01");
238 });
239
240 it("cancels the request to Diffinery when the agent hangs up", async () => {
241 const r = request({ socketPath: sock, method: "POST", path: "/-/model/anthropic/v1/messages", headers: { host: "127.0.0.1:3129" } });
242 r.on("error", () => {});
243 r.end("slow");
244 await until(() => slowOpen === 1);
245 r.destroy();
246 await until(() => slowClosed === 1);
247 });
248
249 it("refuses everything else on Diffinery, however the path is spelled", async () => {
250 const before = seen.length;
251 for (const url of ["/api/v3/user", "/owner/repo.git/info/refs?service=git-receive-pack", "/-/model/../api/v3/user", "/-/model/%2e%2e/api/v3/user", "/-/model/anthropic/%2E%2E/%2E%2E/api", "/-/model%2fanthropic", "/-/model/a\\..\\..\\api", "//evil.example/-/model/x", "http://evil.example/-/model/anthropic/v1/messages", "/-/modelx/anthropic"]) {
252 expect((await send("GET", url)).status, url).toBe(403);
253 }
254 expect(seen.length).toBe(before);
255 expect(logs.some((l) => l.includes("refused GET /api/v3/user"))).toBe(true);
256 });
257 });
258});