| 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 IncomingHttpHeaders, type Server } from "node:http"; |
| 4 | import { request as httpsRequest } from "node:https"; |
| 5 | import net from "node:net"; |
| 6 | import os from "node:os"; |
| 7 | import path from "node:path"; |
| 8 | |
| 9 | /** |
| 10 | * The jail an agent task runs in when it runs in someone's web terminal (phase 4b; Security's design). |
| 11 | * |
| 12 | * The terminal's sandbox holds the person's own Claude or ChatGPT sign-in, so the agent, which content in the |
| 13 | * repository can steer, runs under bubblewrap: no network of its own (a new network namespace with only |
| 14 | * loopback), a new PID namespace, no capabilities, an empty $HOME except the CLI's own sign-in, and nothing of |
| 15 | * the sandbox's work directory but the task's own clone. The person's checkout, its .git (with the sandbox's |
| 16 | * git credential) and the runner's state are not there; the checkout's objects are, read-only, because the |
| 17 | * task's clone borrows them (`git clone --shared`). |
| 18 | * |
| 19 | * The jail's only way out is a CONNECT proxy, outside it, that opens tunnels to the hosts in JAIL_HOSTS and |
| 20 | * nothing else. It never terminates TLS and handles no tokens: the CLI signs itself in, as in the terminal. |
| 21 | * Several of those hosts sit behind CDNs that serve every site they host on the same addresses, so a tunnel |
| 22 | * opens only once the client's TLS ClientHello names the same host (see `clientHelloServerName`). |
| 23 | * Inside, a relay on 127.0.0.1 forwards to the proxy's Unix socket and HTTPS_PROXY points at it. |
| 24 | * |
| 25 | * `finish` jails have no way out at all: they commit what the agent left and write a git bundle, which the |
| 26 | * runner, outside, pushes with the sandbox's credential. |
| 27 | * |
| 28 | * Headless runs (phase 5d: tasks, plans, reviews and coordinators in a sandbox of their own) use `--egress model`. |
| 29 | * They have no sign-in and reach no vendor: their CONNECT proxy opens tunnels to the package registries only, and |
| 30 | * their model requests go, as plain HTTP on 127.0.0.1, to a second proxy outside the jail. That one forwards only |
| 31 | * Diffinery's model endpoints (`/-/model/...`) to PUBLIC_URL over TLS, with the run's model token, which it holds |
| 32 | * and the jail never sees: whatever authorization the agent sends is dropped. |
| 33 | * |
| 34 | * agent.mjs --jail --root /workspace agent --dir /workspace/task-1-1 --out /workspace/task-1-1.out --cli claude-code -- claude -p ... |
| 35 | * agent.mjs --jail --root /workspace agent --dir /workspace/task-1-1 --out /workspace/task-1-1.out --egress model -- claude -p ... |
| 36 | */ |
| 37 | |
| 38 | /** The CLI vendors' hosts a terminal task reaches with the person's sign-in (port 443). */ |
| 39 | const CLI_HOSTS = [ |
| 40 | // Claude Code: the API, and its sign-in's token refresh. |
| 41 | "api.anthropic.com", |
| 42 | "console.anthropic.com", |
| 43 | "platform.claude.com", |
| 44 | // Codex on a ChatGPT plan: its backend and token refresh. |
| 45 | "chatgpt.com", |
| 46 | "auth.openai.com", |
| 47 | ]; |
| 48 | |
| 49 | /** Package registries, download hosts where they have their own (uploads go to other hosts). */ |
| 50 | export const REGISTRY_HOSTS = [ |
| 51 | "registry.npmjs.org", |
| 52 | "registry.yarnpkg.com", |
| 53 | "pypi.org", |
| 54 | "files.pythonhosted.org", |
| 55 | "index.crates.io", |
| 56 | "static.crates.io", |
| 57 | "rubygems.org", |
| 58 | "index.rubygems.org", |
| 59 | "repo1.maven.org", |
| 60 | "repo.maven.apache.org", |
| 61 | "deb.debian.org", |
| 62 | "security.debian.org", |
| 63 | "archive.ubuntu.com", |
| 64 | "security.ubuntu.com", |
| 65 | ]; |
| 66 | |
| 67 | /** Where a terminal task may connect, by name (port 443, or 80 for package mirrors' plain HTTP). */ |
| 68 | export const JAIL_HOSTS = [...CLI_HOSTS, ...REGISTRY_HOSTS]; |
| 69 | |
| 70 | /** How a jail reaches out: `cli`, a terminal task's (its CLI's vendor and the registries); `model`, a headless run's. */ |
| 71 | export type JailEgress = "cli" | "model"; |
| 72 | |
| 73 | /** Hosts the plain-HTTP side of the proxy serves (apt's default sources), for GET and HEAD only. */ |
| 74 | const PLAIN_HTTP_HOSTS = new Set(["deb.debian.org", "security.debian.org", "archive.ubuntu.com", "security.ubuntu.com"]); |
| 75 | |
| 76 | export function jailAllows(host: string, port: number, egress: JailEgress = "cli"): boolean { |
| 77 | const h = host.toLowerCase().replace(/\.$/, ""); |
| 78 | return (port === 443 && (egress === "model" ? REGISTRY_HOSTS : JAIL_HOSTS).includes(h)) || (port === 80 && PLAIN_HTTP_HOSTS.has(h)); |
| 79 | } |
| 80 | |
| 81 | /** Exit code when the jail itself can't start: nothing ran. */ |
| 82 | export const JAIL_FAILED = 125; |
| 83 | |
| 84 | const RELAY_PORT = 3128; |
| 85 | /** Where a headless run's model requests go inside its jail: `*_BASE_URL` points here (MODEL_URL). */ |
| 86 | const MODEL_PORT = 3129; |
| 87 | export const MODEL_URL = `http://127.0.0.1:${MODEL_PORT}`; |
| 88 | |
| 89 | /** |
| 90 | * Runs inside the jail: listens on 127.0.0.1, a port per proxy outside (`port=socket`, before the command), |
| 91 | * forwarding each connection to that proxy's Unix socket, then runs the command and exits with it. |
| 92 | */ |
| 93 | const RELAY = String.raw` |
| 94 | import net from "node:net"; |
| 95 | import os from "node:os"; |
| 96 | import { spawn } from "node:child_process"; |
| 97 | const args = process.argv.slice(2); |
| 98 | const sep = args.indexOf("--"); |
| 99 | const pairs = args.slice(0, sep).map((a) => [Number(a.slice(0, a.indexOf("="))), a.slice(a.indexOf("=") + 1)]); |
| 100 | const cmd = args.slice(sep + 1); |
| 101 | let listening = 0; |
| 102 | for (const [port, sock] of pairs) { |
| 103 | const server = net.createServer((c) => { |
| 104 | const u = net.connect(sock); |
| 105 | c.pipe(u); |
| 106 | u.pipe(c); |
| 107 | // Either side hanging up closes the other, so a request the agent gave up on is cancelled outside too. |
| 108 | c.on("close", () => u.destroy()); |
| 109 | u.on("close", () => c.destroy()); |
| 110 | c.on("error", () => u.destroy()); |
| 111 | u.on("error", () => c.destroy()); |
| 112 | }); |
| 113 | server.on("error", (e) => { |
| 114 | process.stderr.write("diffinery: the jail's proxy relay failed: " + e.message + "\n"); |
| 115 | process.exit(${JAIL_FAILED}); |
| 116 | }); |
| 117 | server.listen(port, "127.0.0.1", () => { |
| 118 | if (++listening < pairs.length) return; |
| 119 | const child = spawn(cmd[0], cmd.slice(1), { stdio: "inherit" }); |
| 120 | child.on("error", (e) => { |
| 121 | process.stderr.write("diffinery: " + cmd[0] + ": " + e.message + "\n"); |
| 122 | process.exit(127); |
| 123 | }); |
| 124 | child.on("exit", (code, signal) => process.exit(code ?? 128 + (os.constants.signals[signal] ?? 1))); |
| 125 | }); |
| 126 | } |
| 127 | `; |
| 128 | |
| 129 | export interface JailArgs { |
| 130 | mode: "agent" | "finish"; |
| 131 | root: string; |
| 132 | dir: string; |
| 133 | out: string; |
| 134 | cli?: "claude-code" | "codex"; |
| 135 | egress: JailEgress; |
| 136 | cmd: string[]; |
| 137 | } |
| 138 | |
| 139 | export function parseJailArgs(argv: string[], cwd = process.cwd()): JailArgs { |
| 140 | const sep = argv.indexOf("--"); |
| 141 | if (sep < 0 || sep === argv.length - 1) throw new Error("usage: --jail --root <dir> agent|finish --dir <dir> --out <dir> [--cli claude-code|codex | --egress model] -- <command...>"); |
| 142 | const opts = argv.slice(0, sep); |
| 143 | const cmd = argv.slice(sep + 1); |
| 144 | const get = (name: string) => { |
| 145 | const i = opts.indexOf(name); |
| 146 | return i >= 0 ? opts[i + 1] : undefined; |
| 147 | }; |
| 148 | const mode = opts.find((o) => o === "agent" || o === "finish") as JailArgs["mode"] | undefined; |
| 149 | const root = get("--root"); |
| 150 | const dir = get("--dir"); |
| 151 | const out = get("--out"); |
| 152 | const cli = get("--cli"); |
| 153 | const egress = get("--egress") ?? "cli"; |
| 154 | if (!mode || !root || !dir || !out) throw new Error("--jail needs agent or finish, --root, --dir and --out"); |
| 155 | if (cli !== undefined && cli !== "claude-code" && cli !== "codex") throw new Error(`unknown --cli ${cli}`); |
| 156 | if (egress !== "cli" && egress !== "model") throw new Error(`unknown --egress ${egress}`); |
| 157 | // A headless run has no sign-in to bring. |
| 158 | if (egress === "model" && (cli || mode !== "agent")) throw new Error("--egress model is for agent jails, without --cli"); |
| 159 | const abs = (p: string) => path.resolve(cwd, p); |
| 160 | const a: JailArgs = { mode, root: abs(root), dir: abs(dir), out: abs(out), cli, egress, cmd }; |
| 161 | // The task's own directories: inside the work root, beside the person's checkout and never in it. |
| 162 | const repo = path.join(a.root, "repo"); |
| 163 | for (const p of [a.dir, a.out]) { |
| 164 | if (path.dirname(p) !== a.root || p === repo || path.basename(p).startsWith(".")) throw new Error(`${p} isn't a task directory of ${a.root}`); |
| 165 | } |
| 166 | if (a.dir === a.out) throw new Error("--dir and --out must differ"); |
| 167 | return a; |
| 168 | } |
| 169 | |
| 170 | const under = (p: string, root: string) => p === root || p.startsWith(root.endsWith("/") ? root : `${root}/`); |
| 171 | |
| 172 | function which(name: string, PATH: string): string | null { |
| 173 | if (name.includes("/")) return existsSync(name) ? name : null; |
| 174 | for (const d of PATH.split(":").filter(Boolean)) { |
| 175 | const p = path.join(d, name); |
| 176 | try { |
| 177 | accessSync(p, constants.X_OK); |
| 178 | if (statSync(p).isFile()) return p; |
| 179 | } catch { |
| 180 | // not here |
| 181 | } |
| 182 | } |
| 183 | return null; |
| 184 | } |
| 185 | |
| 186 | function findBwrap(): string | null { |
| 187 | return which("bwrap", process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"); |
| 188 | } |
| 189 | |
| 190 | /** The files of its home directory each CLI gets in the jail, as copies (paths relative to $HOME). */ |
| 191 | export const CLI_FILES: Record<"claude-code" | "codex", string[]> = { |
| 192 | "claude-code": [".claude/.credentials.json", ".claude.json"], |
| 193 | codex: [".codex/auth.json", ".codex/config.toml"], |
| 194 | }; |
| 195 | |
| 196 | /** Variables the jail keeps from the sandbox's environment; everything else is cleared. */ |
| 197 | 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", "DIFFINERY_TASK_PROMPT"]; |
| 198 | |
| 199 | /** |
| 200 | * What a headless run keeps besides (Diffinery's agentEnv): where each provider's model endpoints are, all on |
| 201 | * MODEL_URL, a stand-in for each API key (the real token stays outside), and the model a route pins. |
| 202 | */ |
| 203 | export const MODEL_ENV = [ |
| 204 | "ANTHROPIC_BASE_URL", |
| 205 | "ANTHROPIC_API_KEY", |
| 206 | "ANTHROPIC_AUTH_TOKEN", |
| 207 | "ANTHROPIC_MODEL", |
| 208 | "ANTHROPIC_DEFAULT_OPUS_MODEL", |
| 209 | "ANTHROPIC_DEFAULT_SONNET_MODEL", |
| 210 | "ANTHROPIC_DEFAULT_HAIKU_MODEL", |
| 211 | "ANTHROPIC_SMALL_FAST_MODEL", |
| 212 | "CLAUDE_CODE_SUBAGENT_MODEL", |
| 213 | "OPENAI_BASE_URL", |
| 214 | "OPENAI_API_KEY", |
| 215 | "CODEX_API_KEY", |
| 216 | "OPENROUTER_BASE_URL", |
| 217 | "OPENROUTER_API_KEY", |
| 218 | "DIFFINERY_MODEL", |
| 219 | ]; |
| 220 | |
| 221 | /** The run's model token and where Diffinery is: read by the jail's model proxy, outside, never kept inside. */ |
| 222 | export const MODEL_TOKEN_ENV = "DIFFINERY_MODEL_TOKEN"; |
| 223 | export const MODEL_ORIGIN_ENV = "DIFFINERY_MODEL_ORIGIN"; |
| 224 | |
| 225 | /** |
| 226 | * Directories a test pool shares with every jail, read-write (colon-separated). Only the runner's own environment |
| 227 | * sets it, and only in end-to-end tests, whose commands keep their scripts and results in a temporary directory. |
| 228 | */ |
| 229 | export const SHARED_ENV = "DIFFINERY_JAIL_SHARED_FOR_TESTS"; |
| 230 | |
| 231 | /** |
| 232 | * A test pool's shared directories, refused (so the jail doesn't start) when one is `/`, holds a hidden path (the |
| 233 | * work root, the home directory, /tmp, /root), or is inside the work root (the checkout and its git credential) or |
| 234 | * the home directory. Shared that way, it would undo the jail's filesystem isolation. |
| 235 | */ |
| 236 | export function sharedDirs(value: string | undefined, hidden: string[], private_: string[]): string[] { |
| 237 | const dirs = (value ?? "").split(":").filter(Boolean); |
| 238 | for (const d of dirs) { |
| 239 | const real = path.isAbsolute(d) && existsSync(d) ? realpathSync(d) : null; |
| 240 | if (!real) throw new Error(`${SHARED_ENV}: ${d} isn't an existing absolute directory`); |
| 241 | const holds = hidden.some((h) => under(h, real) || under(h, d)); |
| 242 | const inside = private_.some((p) => under(real, p) || under(d, p) || (existsSync(p) && under(real, realpathSync(p)))); |
| 243 | if (real === "/" || holds || inside) throw new Error(`${SHARED_ENV}: ${d} is, holds or is inside a directory the jail hides`); |
| 244 | } |
| 245 | return dirs; |
| 246 | } |
| 247 | |
| 248 | /** The bubblewrap command line for a jail (exported for tests). */ |
| 249 | export function bwrapArgs(a: JailArgs, o: { home: string; tools: string; env: NodeJS.ProcessEnv; cliPath?: string | null; nodePath: string }): string[] { |
| 250 | // The home directory (shell history, other sign-ins, other checkouts), the work root (the person's checkout and |
| 251 | // the runner's state) and /tmp are empty in the jail; /root too, where it isn't the home directory. |
| 252 | const hidden = [...new Set(["/tmp", o.home, a.root, ...(existsSync("/root") ? ["/root"] : [])])].sort((x, y) => x.length - y.length); |
| 253 | const args = ["--unshare-all", "--die-with-parent", "--new-session", "--cap-drop", "ALL", "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc"]; |
| 254 | for (const h of hidden) args.push("--tmpfs", h); |
| 255 | const isHidden = (p: string) => hidden.some((h) => under(p, h)); |
| 256 | // A test pool's shared directories, with anything hidden inside them hidden again. |
| 257 | const shared = sharedDirs(o.env[SHARED_ENV], hidden, hidden.filter((h) => h !== "/tmp")); |
| 258 | for (const d of shared) args.push("--bind", d, d); |
| 259 | // What the jail does see: the task's clone and its output directory, the checkout's objects read-only. |
| 260 | 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")); |
| 261 | // The relay, the proxy's socket and the CLI's link: read-only (connecting to a socket needs no write). |
| 262 | args.push("--ro-bind", o.tools, o.tools); |
| 263 | const reveal = (p: string) => { |
| 264 | if (isHidden(p)) args.push("--ro-bind", p, p); |
| 265 | }; |
| 266 | // Node (for the relay) and the CLI, wherever they are installed. |
| 267 | reveal(path.dirname(path.dirname(o.nodePath))); |
| 268 | if (o.cliPath) reveal(path.dirname(o.cliPath)); |
| 269 | // CA bundles the sandbox trusts (the egress CA under web-only egress), wherever they are. |
| 270 | for (const k of ["NODE_EXTRA_CA_CERTS", "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE"]) { |
| 271 | const f = o.env[k]; |
| 272 | if (f && path.isAbsolute(f) && existsSync(f)) reveal(f); |
| 273 | } |
| 274 | // The CLI's own sign-in and settings, at their usual paths so it signs itself in: private copies, never |
| 275 | // written back, so nothing the agent writes there (another account's credentials) reaches the terminal. |
| 276 | if (a.mode === "agent" && a.cli) { |
| 277 | for (const f of CLI_FILES[a.cli]) { |
| 278 | const copy = path.join(o.tools, "home", f.replace(/\//g, "_")); |
| 279 | if (!existsSync(copy)) continue; |
| 280 | args.push("--dir", path.dirname(path.join(o.home, f)), "--bind", copy, path.join(o.home, f)); |
| 281 | } |
| 282 | } |
| 283 | const PATH = [path.join(o.tools, "bin"), ...(o.env.PATH ?? "/usr/local/bin:/usr/bin:/bin").split(":").filter((d) => d && !isHidden(d))].join(":"); |
| 284 | args.push("--chdir", a.dir, "--clearenv", "--setenv", "PATH", PATH, "--setenv", "HOME", o.home, "--setenv", "TMPDIR", "/tmp", "--setenv", "DIFFINERY_TASK_OUT", a.out); |
| 285 | for (const k of [...KEEP_ENV, ...(a.egress === "model" ? MODEL_ENV : [])]) if (o.env[k] !== undefined) args.push("--setenv", k, o.env[k]!); |
| 286 | if (a.mode === "agent") { |
| 287 | // Claude Code skips its permission prompts when told it's in a sandbox (it runs as root here). |
| 288 | args.push("--setenv", "IS_SANDBOX", "1", "--setenv", "DISABLE_AUTOUPDATER", "1", "--setenv", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1"); |
| 289 | const proxy = `http://127.0.0.1:${RELAY_PORT}`; |
| 290 | for (const k of ["HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy"]) args.push("--setenv", k, proxy); |
| 291 | // Model requests go straight to the relay's MODEL_URL, not through the CONNECT proxy. |
| 292 | if (a.egress === "model") for (const k of ["NO_PROXY", "no_proxy"]) args.push("--setenv", k, "127.0.0.1,localhost"); |
| 293 | } |
| 294 | return args; |
| 295 | } |
| 296 | |
| 297 | const MODEL_IDLE_MS = 10 * 60_000; |
| 298 | |
| 299 | /** Request headers the model proxy never forwards: hop-by-hop, the agent's own credentials and cookies, proxy headers. */ |
| 300 | const MODEL_DROP = new Set(["host", "connection", "keep-alive", "proxy-connection", "proxy-authorization", "transfer-encoding", "te", "trailer", "upgrade", "authorization", "x-api-key", "cookie", "forwarded", "x-forwarded-for", "x-forwarded-host", "x-forwarded-proto"]); |
| 301 | |
| 302 | /** |
| 303 | * The model proxy outside a headless run's jail: plain HTTP on a Unix socket in, Diffinery's model endpoints out. |
| 304 | * It forwards only paths under `/-/model/` (as sent: one that a URL parser would rewrite, with `..` or encoded |
| 305 | * dots, is refused), to `origin`, with the run's token as its only credential. |
| 306 | */ |
| 307 | export function startModelProxy(listen: string | number, origin: string, token: string, log: (line: string) => void = (l) => process.stderr.write(`${l}\n`)): Promise<Server> { |
| 308 | const base = new URL(origin); |
| 309 | if ((base.protocol !== "https:" && base.protocol !== "http:") || base.username || base.password) throw new Error(`${MODEL_ORIGIN_ENV} must be an http(s) origin`); |
| 310 | const server = createServer((req, res) => { |
| 311 | const raw = req.url ?? ""; |
| 312 | let target: URL | null = null; |
| 313 | try { |
| 314 | target = raw.startsWith("/") && !raw.startsWith("//") ? new URL(raw, base.origin) : null; |
| 315 | } catch { |
| 316 | target = null; |
| 317 | } |
| 318 | const asSent = raw.split("?")[0]!; |
| 319 | if (!target || target.origin !== base.origin || target.pathname !== asSent || !target.pathname.startsWith("/-/model/") || /%2e|%2f|%5c|\\/i.test(asSent)) { |
| 320 | log(`diffinery: refused ${req.method} ${raw.slice(0, 200)} from the run (only Diffinery's model endpoints, /-/model/...)`); |
| 321 | res.writeHead(403, { "content-type": "text/plain" }).end("Only Diffinery's model endpoints (/-/model/...) are reachable here.\n"); |
| 322 | req.resume(); |
| 323 | return; |
| 324 | } |
| 325 | const headers: IncomingHttpHeaders = {}; |
| 326 | for (const [k, v] of Object.entries(req.headers)) if (!MODEL_DROP.has(k) && v !== undefined) headers[k] = v; |
| 327 | headers.authorization = `Bearer ${token}`; |
| 328 | const send = base.protocol === "https:" ? httpsRequest : httpRequest; |
| 329 | const up = send(target, { method: req.method, headers }, (r) => { |
| 330 | const out = { ...r.headers }; |
| 331 | delete out["set-cookie"]; |
| 332 | res.writeHead(r.statusCode ?? 502, out); |
| 333 | r.pipe(res); |
| 334 | }); |
| 335 | // The agent hung up: cancel the request to Diffinery, which stops the provider's answer (and what it costs). |
| 336 | res.on("close", () => { |
| 337 | if (!res.writableFinished) up.destroy(); |
| 338 | }); |
| 339 | // Nor does an answer that has stalled for 10 minutes hold on (Diffinery frees its slot after 15). |
| 340 | up.setTimeout(MODEL_IDLE_MS, () => up.destroy(new Error("no data for 10 minutes"))); |
| 341 | up.on("error", (e) => { |
| 342 | if (res.destroyed) return; |
| 343 | log(`diffinery: the model proxy couldn't reach ${base.host}: ${e.message}`); |
| 344 | if (!res.headersSent) res.writeHead(502, { "content-type": "text/plain" }).end("Diffinery's model proxy couldn't be reached.\n"); |
| 345 | else res.destroy(); |
| 346 | }); |
| 347 | req.pipe(up); |
| 348 | }); |
| 349 | return new Promise((resolve, reject) => { |
| 350 | server.once("error", reject); |
| 351 | server.listen(listen, () => resolve(server)); |
| 352 | }); |
| 353 | } |
| 354 | |
| 355 | const CLIENT_HELLO_TIMEOUT_MS = 10_000; |
| 356 | /** A ClientHello spread over more than this is refused. */ |
| 357 | const MAX_CLIENT_HELLO_BYTES = 64 * 1024; |
| 358 | |
| 359 | /** |
| 360 | * The server name a TLS client's first bytes ask for (SNI): the name, lower case; `null` when they aren't a |
| 361 | * ClientHello, name no single host name, or carry Encrypted Client Hello (whose real name is hidden); or |
| 362 | * `undefined` while more bytes are needed. Handles a ClientHello split over several records. |
| 363 | */ |
| 364 | export function clientHelloServerName(data: Uint8Array): string | null | undefined { |
| 365 | const fragments: Uint8Array[] = []; |
| 366 | let have = 0; |
| 367 | let pos = 0; |
| 368 | for (;;) { |
| 369 | if (data.length < pos + 5) return data.length > MAX_CLIENT_HELLO_BYTES ? null : undefined; |
| 370 | if (data[pos] !== 0x16) return null; // not a handshake record |
| 371 | const len = (data[pos + 3]! << 8) | data[pos + 4]!; |
| 372 | if (len === 0 || len > 16384) return null; |
| 373 | if (data.length < pos + 5 + len) return pos + 5 + len > MAX_CLIENT_HELLO_BYTES ? null : undefined; |
| 374 | fragments.push(data.subarray(pos + 5, pos + 5 + len)); |
| 375 | have += len; |
| 376 | pos += 5 + len; |
| 377 | const hs = Buffer.concat(fragments, have); |
| 378 | if (hs[0] !== 0x01) return null; // not a ClientHello |
| 379 | if (hs.length < 4) continue; |
| 380 | const size = (hs[1]! << 16) | (hs[2]! << 8) | hs[3]!; |
| 381 | if (size + 4 > MAX_CLIENT_HELLO_BYTES) return null; |
| 382 | if (hs.length < size + 4) continue; |
| 383 | try { |
| 384 | return serverNameOf(hs.subarray(4, 4 + size)); |
| 385 | } catch { |
| 386 | return null; |
| 387 | } |
| 388 | } |
| 389 | } |
| 390 | |
| 391 | function serverNameOf(b: Buffer): string | null { |
| 392 | const at = (n: number) => { |
| 393 | if (n >= b.length) throw new RangeError("short ClientHello"); |
| 394 | return b[n]!; |
| 395 | }; |
| 396 | const u16 = (n: number) => (at(n) << 8) | at(n + 1); |
| 397 | let p = 2 + 32; // version, random |
| 398 | p += 1 + at(p); // session id |
| 399 | p += 2 + u16(p); // cipher suites |
| 400 | p += 1 + at(p); // compression methods |
| 401 | if (p + 2 > b.length) return null; // no extensions, so no server name |
| 402 | const end = p + 2 + u16(p); |
| 403 | if (end > b.length) return null; |
| 404 | p += 2; |
| 405 | const names: string[] = []; |
| 406 | while (p < end) { |
| 407 | const type = u16(p); |
| 408 | const len = u16(p + 2); |
| 409 | const body = p + 4; |
| 410 | if (body + len > end) return null; |
| 411 | if (type === 0xfe0d) return null; // encrypted_client_hello |
| 412 | if (type === 0x0000) { |
| 413 | const listEnd = body + 2 + u16(body); |
| 414 | if (listEnd > body + len) return null; |
| 415 | for (let q = body + 2; q < listEnd; ) { |
| 416 | const nameType = at(q); |
| 417 | const nameLen = u16(q + 1); |
| 418 | if (q + 3 + nameLen > listEnd) return null; |
| 419 | if (nameType === 0) names.push(b.subarray(q + 3, q + 3 + nameLen).toString("latin1")); |
| 420 | q += 3 + nameLen; |
| 421 | } |
| 422 | } |
| 423 | p = body + len; |
| 424 | } |
| 425 | if (names.length !== 1 || !/^[A-Za-z0-9.-]+$/.test(names[0]!)) return null; |
| 426 | return names[0]!.toLowerCase().replace(/\.$/, ""); |
| 427 | } |
| 428 | |
| 429 | /** The CONNECT proxy outside the jail: tunnels to JAIL_HOSTS by name, plain-HTTP downloads from package mirrors. */ |
| 430 | export function startJailProxy( |
| 431 | listen: string | number, |
| 432 | log: (line: string) => void = (l) => process.stderr.write(`${l}\n`), |
| 433 | connectUpstream: (port: number, host: string, onConnect: () => void) => net.Socket = (port, host, onConnect) => net.connect(port, host, onConnect), |
| 434 | egress: JailEgress = "cli", |
| 435 | ): Promise<Server> { |
| 436 | const hostsDoc = egress === "model" ? "/docs/agents/tasks#what-a-run-can-reach" : "/docs/agents/terminal#what-a-task-can-reach"; |
| 437 | const who = egress === "model" ? "an agent run" : "a terminal task"; |
| 438 | const server = createServer((req, res) => { |
| 439 | let url: URL; |
| 440 | try { |
| 441 | url = new URL(req.url ?? ""); |
| 442 | } catch { |
| 443 | res.writeHead(400).end(); |
| 444 | return; |
| 445 | } |
| 446 | const port = Number(url.port || 80); |
| 447 | if (url.protocol !== "http:" || !jailAllows(url.hostname, port, egress) || (req.method !== "GET" && req.method !== "HEAD")) { |
| 448 | log(`diffinery: refused ${req.method} ${url.protocol}//${url.host} from the task (only the hosts in ${hostsDoc})`); |
| 449 | res.writeHead(403, { "content-type": "text/plain" }).end(`${url.host} isn't reachable from ${who}.\n`); |
| 450 | return; |
| 451 | } |
| 452 | const headers = { ...req.headers }; |
| 453 | delete headers["proxy-connection"]; |
| 454 | delete headers["proxy-authorization"]; |
| 455 | // The host the proxy allowed, never another one the tool names in Host. |
| 456 | headers.host = url.host; |
| 457 | const up = httpRequest({ host: url.hostname, port, path: `${url.pathname}${url.search}`, method: req.method, headers }, (r) => { |
| 458 | res.writeHead(r.statusCode ?? 502, r.headers); |
| 459 | r.pipe(res); |
| 460 | }); |
| 461 | up.on("error", () => res.writeHead(502).end()); |
| 462 | up.end(); |
| 463 | }); |
| 464 | server.on("connect", (req, socket, head) => { |
| 465 | const m = /^\[?([A-Za-z0-9.:-]+?)\]?:(\d+)$/.exec(req.url ?? ""); |
| 466 | const host = m?.[1] ?? ""; |
| 467 | const port = Number(m?.[2] ?? 0); |
| 468 | socket.on("error", () => socket.destroy()); |
| 469 | if (!m || port !== 443 || !jailAllows(host, port, egress) || net.isIP(host)) { |
| 470 | log(`diffinery: refused a connection to ${req.url} from the task (only the hosts in ${hostsDoc})`); |
| 471 | socket.end(`HTTP/1.1 403 Forbidden\r\ncontent-type: text/plain\r\n\r\nnot reachable from ${who}\n`); |
| 472 | return; |
| 473 | } |
| 474 | // The tunnel opens only once the client's ClientHello asks for this same host: CONNECT to an allowed |
| 475 | // host behind a CDN, then TLS naming another site on that CDN, would otherwise reach that site. |
| 476 | socket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); |
| 477 | const want = host.toLowerCase().replace(/\.$/, ""); |
| 478 | let buf = Buffer.from(head); |
| 479 | const refuse = (why: string) => { |
| 480 | log(`diffinery: refused a connection to ${req.url} from the task: ${why}`); |
| 481 | socket.destroy(); |
| 482 | }; |
| 483 | const timer = setTimeout(() => refuse("no TLS ClientHello"), CLIENT_HELLO_TIMEOUT_MS); |
| 484 | const check = () => { |
| 485 | const name = clientHelloServerName(buf); |
| 486 | if (name === undefined) return; |
| 487 | socket.off("data", onData); |
| 488 | socket.pause(); |
| 489 | clearTimeout(timer); |
| 490 | if (name !== want) return refuse(name === null ? "its TLS ClientHello names no single server, or hides it (Encrypted Client Hello)" : `its TLS ClientHello asks for ${name}`); |
| 491 | const up = connectUpstream(port, host, () => { |
| 492 | up.write(buf); |
| 493 | up.pipe(socket); |
| 494 | socket.pipe(up); |
| 495 | socket.resume(); |
| 496 | }); |
| 497 | up.on("error", () => socket.destroy()); |
| 498 | }; |
| 499 | const onData = (d: Buffer) => { |
| 500 | buf = Buffer.concat([buf, d]); |
| 501 | check(); |
| 502 | }; |
| 503 | socket.on("data", onData); |
| 504 | socket.on("close", () => clearTimeout(timer)); |
| 505 | if (buf.length) check(); |
| 506 | }); |
| 507 | return new Promise((resolve, reject) => { |
| 508 | server.once("error", reject); |
| 509 | server.listen(listen, () => resolve(server)); |
| 510 | }); |
| 511 | } |
| 512 | |
| 513 | /** `agent.mjs --jail ...`: runs the command in the jail and exits with its code (JAIL_FAILED if it couldn't). */ |
| 514 | export async function runJail(argv: string[]): Promise<number> { |
| 515 | const fail = (m: string) => { |
| 516 | process.stderr.write(`diffinery: the task's jail couldn't start: ${m}\n`); |
| 517 | return JAIL_FAILED; |
| 518 | }; |
| 519 | let a: JailArgs; |
| 520 | try { |
| 521 | a = parseJailArgs(argv); |
| 522 | } catch (e) { |
| 523 | return fail((e as Error).message); |
| 524 | } |
| 525 | const bwrap = findBwrap(); |
| 526 | if (!bwrap) return fail("bubblewrap (bwrap) isn't installed in this sandbox"); |
| 527 | for (const p of [a.dir, a.out]) if (!existsSync(p)) return fail(`${p} doesn't exist`); |
| 528 | // bwrap's own setup errors exit 1 like any command would: try the namespaces first, so a jail that can't |
| 529 | // exist is never mistaken for an agent that failed. |
| 530 | const probe = spawnSync(bwrap, ["--unshare-all", "--die-with-parent", "--cap-drop", "ALL", "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp", "--", "true"], { encoding: "utf8" }); |
| 531 | if (probe.status !== 0) return fail(`bubblewrap can't create its namespaces here: ${(probe.stderr || probe.error?.message || `exit ${probe.status}`).trim()}`); |
| 532 | // A headless run's model token stays out here, with its model proxy: nothing kept in the jail may carry it. |
| 533 | const token = process.env[MODEL_TOKEN_ENV] ?? ""; |
| 534 | const origin = process.env[MODEL_ORIGIN_ENV] ?? ""; |
| 535 | if (a.egress === "model") { |
| 536 | if (!token || !origin) return fail(`--egress model needs ${MODEL_TOKEN_ENV} and ${MODEL_ORIGIN_ENV}`); |
| 537 | if (!/^https?:\/\/[^/@]+$/.test(origin.replace(/\/$/, ""))) return fail(`${MODEL_ORIGIN_ENV} must be an http(s) origin`); |
| 538 | const leak = [...KEEP_ENV, ...MODEL_ENV].find((k) => process.env[k]?.includes(token)); |
| 539 | if (leak) return fail(`${leak} holds the run's model token, which must stay outside the jail`); |
| 540 | } |
| 541 | const home = os.homedir(); |
| 542 | let tools: string; |
| 543 | try { |
| 544 | tools = mkdtempSync(path.join(a.root, ".jail-")); |
| 545 | } catch (e) { |
| 546 | return fail((e as Error).message); |
| 547 | } |
| 548 | const servers: Server[] = []; |
| 549 | try { |
| 550 | // The CLI, found as the terminal finds it, linked into the jail's PATH. |
| 551 | const cliPath = which(a.cmd[0]!, process.env.PATH ?? ""); |
| 552 | const real = cliPath ? realpathSync(cliPath) : null; |
| 553 | const bin = path.join(tools, "bin"); |
| 554 | mkdirSync(bin); |
| 555 | if (real) symlinkSync(real, path.join(bin, path.basename(a.cmd[0]!))); |
| 556 | mkdirSync(path.join(tools, "home")); |
| 557 | 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, "_"))); |
| 558 | let cmd = a.cmd; |
| 559 | if (a.mode === "agent") { |
| 560 | const sock = path.join(tools, "proxy.sock"); |
| 561 | servers.push(await startJailProxy(sock, undefined, undefined, a.egress)); |
| 562 | const relays = [`${RELAY_PORT}=${sock}`]; |
| 563 | if (a.egress === "model") { |
| 564 | const modelSock = path.join(tools, "model.sock"); |
| 565 | servers.push(await startModelProxy(modelSock, origin, token)); |
| 566 | relays.push(`${MODEL_PORT}=${modelSock}`); |
| 567 | } |
| 568 | writeFileSync(path.join(tools, "relay.mjs"), RELAY); |
| 569 | cmd = [process.execPath, path.join(tools, "relay.mjs"), ...relays, "--", ...a.cmd]; |
| 570 | } |
| 571 | const args = [...bwrapArgs(a, { home, tools, env: process.env, cliPath: real, nodePath: process.execPath }), "--", ...cmd]; |
| 572 | const code = await new Promise<number>((resolve) => { |
| 573 | const child = spawn(bwrap, args, { stdio: "inherit" }); |
| 574 | const stop = (sig: NodeJS.Signals) => child.kill(sig); |
| 575 | process.on("SIGTERM", stop); |
| 576 | process.on("SIGINT", stop); |
| 577 | child.on("error", (e) => resolve(fail(e.message))); |
| 578 | child.on("exit", (c, sig) => resolve(c ?? 128 + (os.constants.signals[sig as NodeJS.Signals] ?? 1))); |
| 579 | }); |
| 580 | return code; |
| 581 | } catch (e) { |
| 582 | return fail(e instanceof Error ? e.message : String(e)); |
| 583 | } finally { |
| 584 | for (const s of servers) s.close(); |
| 585 | rmSync(tools, { recursive: true, force: true }); |
| 586 | } |
| 587 | } |