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.

120 lines · 6.9 KBRaw History
1import { mkdirSync, writeFileSync } from "node:fs";
2import { request } 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, parseJailArgs, startJailProxy } 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", 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});