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.

259 lines · 15.3 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 // /run too: the Docker daemon's and BuildKit's sockets live there, and a socket file reaches across network namespaces.
47 for (const h of ["/tmp", "/run", "/home/p", "/w"]) expect(s).toContain(`--tmpfs ${h}`);
48 expect(s).toContain("--bind /w/t /w/t");
49 expect(s).toContain("--bind /w/t.out /w/t.out");
50 expect(s).toContain("--ro-bind /w/repo/.git/objects /w/repo/.git/objects");
51 expect(s).not.toMatch(/\/w\/repo(\/\.git)?(\/config)? /);
52 expect(s).not.toContain("--bind /w/repo/.git ");
53 // The environment is cleared, then given only what it needs: no sandbox secrets.
54 expect(s).not.toContain("secret");
55 expect(s).toContain("--setenv GIT_AUTHOR_NAME bot");
56 expect(s).toContain("--setenv HTTPS_PROXY http://127.0.0.1:3128");
57 expect(s).toContain(`--setenv PATH ${tools}/bin:/usr/bin`);
58 // The sign-in is a private copy, and the jail's tools are read-only.
59 expect(s).toContain(`--bind ${tools}/home/.claude_.credentials.json /home/p/.claude/.credentials.json`);
60 expect(s).not.toContain("--bind /home/p/.claude/.credentials.json");
61 expect(s).toContain(`--ro-bind ${tools} ${tools}`);
62 expect(s).not.toContain(".claude.json"); // not copied here, so not bound
63 expect(s).toContain("--chdir /w/t");
64 // A finish jail has no way out and no sign-in.
65 const f = bwrapArgs({ ...a, mode: "finish", cli: undefined }, { home: "/home/p", tools, env: {}, nodePath: "/usr/bin/node" }).join(" ");
66 expect(f).not.toContain("HTTPS_PROXY");
67 expect(f).not.toContain(".claude");
68 });
69
70 describe("its proxy", () => {
71 let dir: string;
72 let sock: string;
73 let close: () => void;
74 const logs: string[] = [];
75 beforeAll(async () => {
76 dir = tmp("jail-");
77 sock = path.join(dir, "p.sock");
78 const server = await startJailProxy(sock, (l) => logs.push(l));
79 close = () => server.close();
80 });
81 afterAll(() => close());
82
83 const connect = (target: string) =>
84 new Promise<string>((resolve) => {
85 const c = net.connect(sock, () => c.write(`CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\n\r\n`));
86 let got = "";
87 c.on("data", (d) => {
88 got += d;
89 if (got.includes("\r\n\r\n")) {
90 c.destroy();
91 resolve(got.split("\r\n")[0]!);
92 }
93 });
94 c.on("end", () => resolve(got.split("\r\n")[0]!));
95 c.on("error", () => resolve(got.split("\r\n")[0] ?? ""));
96 });
97
98 it("refuses tunnels anywhere else, and to the allowed hosts on other ports or by address", async () => {
99 expect(await connect("example.com:443")).toBe("HTTP/1.1 403 Forbidden");
100 expect(await connect("api.anthropic.com:80")).toBe("HTTP/1.1 403 Forbidden");
101 expect(await connect("1.1.1.1:443")).toBe("HTTP/1.1 403 Forbidden");
102 expect(await connect("api.anthropic.com.evil.test:443")).toBe("HTTP/1.1 403 Forbidden");
103 expect(logs.some((l) => l.includes("refused a connection to example.com:443"))).toBe(true);
104 });
105
106 it("serves plain HTTP only to package mirrors, and only downloads", async () => {
107 const send = (method: string, url: string) =>
108 new Promise<number>((resolve) => {
109 const r = request({ socketPath: sock, method, path: url, headers: { host: new URL(url).host } }, (res) => {
110 res.resume();
111 resolve(res.statusCode ?? 0);
112 });
113 r.on("error", () => resolve(0));
114 r.end();
115 });
116 expect(await send("GET", "http://example.com/")).toBe(403);
117 expect(await send("POST", "http://archive.ubuntu.com/ubuntu/")).toBe(403);
118 expect(await send("GET", "http://registry.npmjs.org/left-pad")).toBe(403);
119 });
120 });
121});
122
123describe("a headless run's jail (phase 5d)", () => {
124 const args = (...extra: string[]) => ["--root", "/w", "agent", "--dir", "../task-2-1", "--out", "../task-2-1.out", ...extra, "--", "claude", "-p", "x"];
125 it("takes --egress model, for agent jails without a sign-in", () => {
126 expect(parseJailArgs(args("--egress", "model"), "/w/repo").egress).toBe("model");
127 expect(() => parseJailArgs(args("--egress", "model", "--cli", "claude-code"), "/w/repo")).toThrow(/without --cli/);
128 expect(() => parseJailArgs(["--root", "/w", "finish", "--dir", "../a", "--out", "../b", "--egress", "model", "--", "true"], "/w/repo")).toThrow(/for agent jails/);
129 expect(() => parseJailArgs(args("--egress", "web"), "/w/repo")).toThrow(/unknown --egress/);
130 });
131
132 it("reaches the package registries only: no CLI vendor, and never Diffinery's host", () => {
133 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);
134 for (const h of REGISTRY_HOSTS) expect(jailAllows(h, 443, "model"), h).toBe(true);
135 expect(jailAllows("archive.ubuntu.com", 80, "model")).toBe(true);
136 });
137
138 it("keeps the model variables, never the run's token, and sends model requests around the CONNECT proxy", () => {
139 const a = parseJailArgs(args("--egress", "model"), "/w/repo");
140 const tools = tmp("jail-tools-");
141 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" };
142 const s = bwrapArgs(a, { home: "/home/p", tools, env, nodePath: "/usr/bin/node" }).join(" ");
143 expect(s).toContain(`--setenv ANTHROPIC_BASE_URL ${MODEL_URL}/-/model/anthropic`);
144 expect(s).toContain("--setenv ANTHROPIC_API_KEY diffinery-jail");
145 expect(s).toContain("--setenv OPENAI_API_KEY diffinery-jail");
146 expect(s).not.toContain("dmt_repo_secret");
147 expect(s).not.toContain("d.example");
148 expect(s).toContain("--setenv NO_PROXY 127.0.0.1,localhost");
149 // A terminal task's jail keeps none of them.
150 const t = bwrapArgs({ ...a, egress: "cli", cli: "claude-code" }, { home: "/home/p", tools, env, nodePath: "/usr/bin/node" }).join(" ");
151 expect(t).not.toContain("ANTHROPIC_BASE_URL");
152 expect(t).not.toContain("NO_PROXY");
153 });
154
155 it("shares a test pool's directories, and refuses one that would undo what the jail hides", () => {
156 const e2e = tmp("jail-e2e-");
157 mkdirSync(path.join(e2e, "pool", "s1", "repo"), { recursive: true });
158 mkdirSync(path.join(e2e, "work"));
159 const root = path.join(e2e, "pool", "s1");
160 const home = path.join(e2e, "home");
161 mkdirSync(path.join(home, "sub"), { recursive: true });
162 const work = path.join(e2e, "work");
163 const a = parseJailArgs(["--root", root, "agent", "--dir", "../t", "--out", "../t.out", "--egress", "model", "--", "true"], path.join(root, "repo"));
164 const args = (shared?: string) => bwrapArgs(a, { home, tools: tmp("jail-tools-"), env: shared === undefined ? {} : { [SHARED_ENV]: shared }, nodePath: "/usr/bin/node" }).join(" ");
165 const s = args(work);
166 expect(s.indexOf(`--bind ${work} ${work} `)).toBeGreaterThan(s.indexOf("--tmpfs /tmp "));
167 expect(args()).not.toContain(`--bind ${work} `);
168 // `/`, the work root (with the checkout and its git credential), the home directory, /tmp, anything holding them
169 // or inside the work root or home: shared, they would undo the jail's isolation, so the jail doesn't start.
170 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/);
171 for (const bad of ["relative", "/nonexistent"]) expect(() => args(bad), bad).toThrow(/isn't an existing absolute directory/);
172 });
173
174 describe("its model proxy", () => {
175 let sock: string;
176 let upstream: Server;
177 let close: () => void;
178 const seen: Array<{ method: string; url: string; headers: Record<string, unknown>; body: string }> = [];
179 const logs: string[] = [];
180 let slowOpen = 0;
181 let slowClosed = 0;
182 const until = async (ok: () => boolean) => {
183 for (let i = 0; i < 100 && !ok(); i++) await new Promise((r) => setTimeout(r, 20));
184 expect(ok()).toBe(true);
185 };
186 beforeAll(async () => {
187 upstream = createServer((req, res) => {
188 // A slow answer: it streams until whoever asked goes away.
189 if (req.headers["content-length"] === "4") {
190 slowOpen++;
191 res.writeHead(200, { "content-type": "text/event-stream" });
192 res.write("data: thinking\n\n");
193 res.on("close", () => slowClosed++);
194 return;
195 }
196 let body = "";
197 req.on("data", (d) => (body += d));
198 req.on("end", () => {
199 seen.push({ method: req.method!, url: req.url!, headers: req.headers, body });
200 res.writeHead(200, { "content-type": "text/event-stream", "set-cookie": "s=1" });
201 res.write("data: one\n\n");
202 setTimeout(() => res.end("data: two\n\n"), 20);
203 });
204 });
205 await new Promise<void>((r) => upstream.listen(0, "127.0.0.1", r));
206 const origin = `http://127.0.0.1:${(upstream.address() as net.AddressInfo).port}`;
207 sock = path.join(tmp("jail-model-"), "m.sock");
208 const server = await startModelProxy(sock, origin, "dmt_repo_runtoken", (l) => logs.push(l));
209 close = () => {
210 server.close();
211 upstream.close();
212 };
213 });
214 afterAll(() => close());
215
216 const send = (method: string, url: string, headers: Record<string, string> = {}, body?: string) =>
217 new Promise<{ status: number; body: string; headers: Record<string, unknown> }>((resolve) => {
218 const r = request({ socketPath: sock, method, path: url, headers: { host: "127.0.0.1:3129", ...headers } }, (res) => {
219 let b = "";
220 res.on("data", (d) => (b += d));
221 res.on("end", () => resolve({ status: res.statusCode ?? 0, body: b, headers: res.headers }));
222 });
223 r.on("error", () => resolve({ status: 0, body: "", headers: {} }));
224 r.end(body);
225 });
226
227 it("forwards model requests with the run's token in place of whatever the agent sent, and streams the answer", async () => {
228 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}');
229 expect(r.status).toBe(200);
230 expect(r.body).toBe("data: one\n\ndata: two\n\n");
231 expect(r.headers["set-cookie"]).toBeUndefined();
232 const got = seen.at(-1)!;
233 expect(got.url).toBe("/-/model/anthropic/v1/messages?beta=true");
234 expect(got.body).toBe('{"max_tokens":5}');
235 expect(got.headers.authorization).toBe("Bearer dmt_repo_runtoken");
236 expect(got.headers["x-api-key"]).toBeUndefined();
237 expect(got.headers.cookie).toBeUndefined();
238 expect(got.headers["anthropic-version"]).toBe("2023-06-01");
239 });
240
241 it("cancels the request to Diffinery when the agent hangs up", async () => {
242 const r = request({ socketPath: sock, method: "POST", path: "/-/model/anthropic/v1/messages", headers: { host: "127.0.0.1:3129" } });
243 r.on("error", () => {});
244 r.end("slow");
245 await until(() => slowOpen === 1);
246 r.destroy();
247 await until(() => slowClosed === 1);
248 });
249
250 it("refuses everything else on Diffinery, however the path is spelled", async () => {
251 const before = seen.length;
252 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"]) {
253 expect((await send("GET", url)).status, url).toBe(403);
254 }
255 expect(seen.length).toBe(before);
256 expect(logs.some((l) => l.includes("refused GET /api/v3/user"))).toBe(true);
257 });
258 });
259});