| 1 | import { accessSync, constants, copyFileSync, existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; |
| 2 | import { spawn, spawnSync } from "node:child_process"; |
| 3 | import { createServer, request as httpRequest, type Server } from "node:http"; |
| 4 | import net from "node:net"; |
| 5 | import os from "node:os"; |
| 6 | import 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). */ |
| 29 | export 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. */ |
| 55 | const PLAIN_HTTP_HOSTS = new Set(["deb.debian.org", "security.debian.org", "archive.ubuntu.com", "security.ubuntu.com"]); |
| 56 | |
| 57 | export 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. */ |
| 63 | export const JAIL_FAILED = 125; |
| 64 | |
| 65 | const 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. */ |
| 68 | const RELAY = String.raw` |
| 69 | import net from "node:net"; |
| 70 | import os from "node:os"; |
| 71 | import { spawn } from "node:child_process"; |
| 72 | const [sock, port, ...cmd] = process.argv.slice(2); |
| 73 | const 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 | }); |
| 80 | server.on("error", (e) => { |
| 81 | process.stderr.write("diffinery: the jail's proxy relay failed: " + e.message + "\n"); |
| 82 | process.exit(${JAIL_FAILED}); |
| 83 | }); |
| 84 | server.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 | |
| 94 | export 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 | |
| 103 | export 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 | |
| 130 | const under = (p: string, root: string) => p === root || p.startsWith(root.endsWith("/") ? root : `${root}/`); |
| 131 | |
| 132 | function 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 | |
| 146 | function 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). */ |
| 151 | export 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. */ |
| 157 | const 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). */ |
| 160 | export 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. */ |
| 204 | export 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). */ |
| 254 | export 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 | } |