diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 044d02f4..86e87ca9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,70 @@ jobs: shell: bash working-directory: backend/cli + # Task 7 gave macOS a seatbelt profile for network:"allowlist" — an SBPL + # profile plus an authenticated loopback proxy, built and unit-tested + # entirely from Linux with the platform injected, because no Mac exists on + # this project. `sandbox-exec` (macOS) and `bwrap --unshare-net` (Linux) + # are unrelated OS-level mechanisms underneath the same `Sandbox` API, so a + # green Linux run says nothing about whether seatbelt actually confines a + # real process the way the profile text claims — only this leg's macOS run + # does. See test/sandbox/egress-live-seatbelt.test.ts's doc comment for + # exactly what a red run here would mean. + sandbox: + name: Sandbox (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + # Raised from 20: test/package/ now runs real pip installs through the + # sandbox against real pypi, which the sandbox suite alone never did. + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/setup-bun + # Same step the `test` job already has. The sandbox job never needed it + # until test/package/ joined it: those tests create a real project with + # `tmpdir({ git: true })`, and `git commit` exits 128 on a runner with no + # global identity configured. + - name: Configure git for tests + run: | + git config --global user.email "ci@openscience.dev" + git config --global user.name "OpenScience CI" + git config --global init.defaultBranch main + - name: Install and verify Linux sandbox + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install --yes bubblewrap + # Ubuntu 24.04's host-wide AppArmor policy blocks unprivileged user + # namespaces on the hosted runner before bubblewrap can apply our + # stricter per-process profile. This runner is disposable; enable + # user namespaces for the job, then prove the sandbox can start. + if [[ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]]; then + echo 0 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns + fi + bwrap --ro-bind / / --dev /dev --proc /proc --unshare-pid --die-with-parent -- true + # R is the one backend with no verification anywhere: no runner has + # Rscript by default, and neither does any development machine on this + # project, so its two live tests skip everywhere and it ships on faith. + # r-base-core is the minimal package that provides Rscript. Linux only — + # `brew install r` on the macOS leg costs several minutes for a backend + # whose only platform-specific surface (the sandbox wrapper) is already + # covered there by the Python tests. + - name: Install R so the R installer tests actually run + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get install --yes --no-install-recommends r-base-core + Rscript -e 'cat("Rscript", as.character(getRversion()), "\n")' + # test/package/ carries the merge gate: a governed install under + # network "allowlist", plus the assertion that the shell route to the + # same install is refused. Both legs run it, so the gate is a fact on + # Linux and macOS rather than a claim about one of them. + - run: bun test test/sandbox/ test/package/ + shell: bash + working-directory: backend/cli + test: name: Test runs-on: ubuntu-latest diff --git a/backend/cli/src/cli/cmd/sandbox.ts b/backend/cli/src/cli/cmd/sandbox.ts index 3e345e4d..af2d254e 100644 --- a/backend/cli/src/cli/cmd/sandbox.ts +++ b/backend/cli/src/cli/cmd/sandbox.ts @@ -17,27 +17,39 @@ function printStatus(config?: Config.Sandbox) { const enabled = config?.enabled === true UI.println(`${S.TEXT_NORMAL_BOLD}Execution sandbox${S.TEXT_NORMAL}`) + // Three states, not two. "enabled" describes the CONFIG; whether anything is + // actually confined depends on a backend existing. Keying the sentence off + // `enabled` alone told a Windows user "agent shell commands are confined to + // the workspace" on a machine where `Sandbox.backend()` is "none" and nothing + // confines anything — a false statement about a security property, which is + // the worst kind of wrong thing for this command to print. + const effect = !enabled + ? "run with full user authority" + : d.available + ? "are confined to the workspace" + : "are NOT confined here: no backend on this platform" UI.println( ` status ${enabled ? `${S.TEXT_SUCCESS_BOLD}enabled` : `${S.TEXT_DIM}disabled`}${S.TEXT_NORMAL}` + - `${S.TEXT_DIM} (agent shell commands${enabled ? " are confined to the workspace" : " run with full user authority"})${S.TEXT_NORMAL}`, + `${S.TEXT_DIM} (agent shell commands ${effect})${S.TEXT_NORMAL}`, ) UI.println(` platform ${d.platform}`) UI.println( ` backend ${ d.available ? `${S.TEXT_SUCCESS}${d.backend}${S.TEXT_NORMAL} ${S.TEXT_DIM}(${d.tool})${S.TEXT_NORMAL}` - : `${S.TEXT_WARNING}unavailable${S.TEXT_NORMAL} ${S.TEXT_DIM}— ${d.reason}${S.TEXT_NORMAL}` + : `${S.TEXT_WARNING}unavailable${S.TEXT_NORMAL} ${S.TEXT_DIM}- ${d.reason}${S.TEXT_NORMAL}` }`, ) if (enabled) { - UI.println(` network ${config?.network ?? "deny"}`) + UI.println(` network ${config?.network ?? "allowlist"}`) UI.println(` on missing backend ${config?.onUnavailable ?? "error"}`) if (config?.allowWrite?.length) UI.println(` extra writable ${config.allowWrite.join(", ")}`) + if (config?.allowHosts?.length) UI.println(` extra hosts ${config.allowHosts.join(", ")}`) } if (enabled && !d.available) { UI.println("") UI.println( - ` ${S.TEXT_WARNING_BOLD}Note:${S.TEXT_NORMAL} sandbox is on but no backend exists here — ` + + ` ${S.TEXT_WARNING_BOLD}Note:${S.TEXT_NORMAL} sandbox is on but no backend exists here - ` + `commands run per "${config?.onUnavailable ?? "error"}". It takes effect on machines with a backend.`, ) } @@ -67,14 +79,19 @@ const EnableCommand = cmd({ builder: (yargs: Argv) => yargs .option("network", { - choices: ["allow", "deny"] as const, - describe: "allow or deny network egress from sandboxed commands (default: deny)", + choices: ["deny", "allowlist", "allow"] as const, + describe: "network egress from sandboxed commands: deny, allowlist (default), or allow", }) .option("allow", { type: "string", array: true, describe: "extra absolute path the sandbox may write to (repeatable)", }) + .option("allow-host", { + type: "string", + array: true, + describe: "extra host the sandbox may reach when network is 'allowlist' (repeatable)", + }) .option("on-unavailable", { choices: ["warn", "error", "allow"] as const, describe: "what to do when no backend exists on a machine (default: error)", @@ -84,10 +101,12 @@ const EnableCommand = cmd({ directory: process.cwd(), async fn() { const patch: Partial = { enabled: true } - if (args.network) patch.network = args.network as "allow" | "deny" + if (args.network) patch.network = args.network if (args["on-unavailable"]) patch.onUnavailable = args["on-unavailable"] as "warn" | "error" | "allow" const allow = args.allow as string[] | undefined if (allow?.length) patch.allowWrite = allow + const allowHosts = args["allow-host"] as string[] | undefined + if (allowHosts?.length) patch.allowHosts = allowHosts await Config.setSandbox(patch) UI.empty() UI.println(`${S.TEXT_SUCCESS_BOLD}Sandbox enabled${S.TEXT_NORMAL} ${S.TEXT_DIM}(global config)${S.TEXT_NORMAL}`) @@ -125,7 +144,7 @@ const TestCommand = cmd({ const result = await Sandbox.selfTest() if (!result.available) { const d = Sandbox.describe() - UI.println(`${S.TEXT_WARNING}No sandbox backend available${S.TEXT_NORMAL} — ${d.reason}.`) + UI.println(`${S.TEXT_WARNING}No sandbox backend available${S.TEXT_NORMAL} - ${d.reason}.`) UI.println(`${S.TEXT_DIM}Nothing to test here.${S.TEXT_NORMAL}`) return } @@ -133,6 +152,11 @@ const TestCommand = cmd({ `${S.TEXT_NORMAL_BOLD}Sandbox self-test${S.TEXT_NORMAL} ${S.TEXT_DIM}(${result.backend})${S.TEXT_NORMAL}`, ) for (const c of result.checks) { + // The glyphs below are only reachable when a backend EXISTS, so they + // cannot print on Windows today, where the command exits above. Anything + // printed on a backend-less machine must stay ASCII: a Windows console + // decodes our UTF-8 as its OEM code page, and an em dash arrived as + // "\u0393\u00c7\u00f6" in a real run. Keep that rule if a Windows backend lands. const mark = c.skipped ? `${S.TEXT_DIM}– skip` : c.pass ? `${S.TEXT_SUCCESS}✓ pass` : `${S.TEXT_DANGER}✗ FAIL` UI.println(` ${mark}${S.TEXT_NORMAL} ${c.name}${c.detail ? ` ${S.TEXT_DIM}(${c.detail})${S.TEXT_NORMAL}` : ""}`) } @@ -140,7 +164,7 @@ const TestCommand = cmd({ UI.println( result.ok ? `${S.TEXT_SUCCESS_BOLD}Containment verified.${S.TEXT_NORMAL}` - : `${S.TEXT_DANGER_BOLD}Containment FAILED — do not rely on the sandbox until this passes.${S.TEXT_NORMAL}`, + : `${S.TEXT_DANGER_BOLD}Containment FAILED - do not rely on the sandbox until this passes.${S.TEXT_NORMAL}`, ) }, }) diff --git a/backend/cli/src/compute/jobs.ts b/backend/cli/src/compute/jobs.ts index 8c195747..24c70067 100644 --- a/backend/cli/src/compute/jobs.ts +++ b/backend/cli/src/compute/jobs.ts @@ -9,6 +9,7 @@ import { OpenScience } from "../openscience" import { Shell } from "../shell/shell" import { Instance } from "../project/instance" import { Sandbox } from "../sandbox/sandbox" +import { EgressRuntime } from "../sandbox/egress-runtime" import { Filesystem } from "../util/filesystem" import { ProvenanceEnvelope } from "../science/provenance/envelope" import { ExecutionAuthority } from "../project/execution" @@ -169,6 +170,10 @@ export namespace ComputeJobs { recovery_attempts: z.number().int().nonnegative().optional(), recovery_retry_at: z.string().optional(), session_id: z.string().startsWith("ses_").optional(), + // Persists the whole Decision, including its own sandbox.network — a + // second copy of the persisted enum below `sandbox.network` carries; see + // the comment on ExecutionAuthority.Decision for the downgrade cost of + // widening either one. authority: ExecutionAuthority.Decision.optional(), scope: z .object({ @@ -180,8 +185,11 @@ export namespace ComputeJobs { .object({ requested: z.boolean(), enforced: z.boolean(), - backend: z.enum(["seatbelt", "bubblewrap", "none"]), - network: z.enum(["allow", "deny"]), + backend: z.enum(["seatbelt", "bubblewrap", "appcontainer", "none"]), + // Persisted — widening this costs an older binary its ability to + // read a newer record. `authority.sandbox.network` above is the same + // enum persisted a second time; see ExecutionAuthority.Decision. + network: z.enum(["deny", "allowlist", "allow"]), warning: z.string().optional(), }) .optional(), @@ -246,6 +254,9 @@ export namespace ComputeJobs { type Launch = { argv: string[] sandbox?: Job["sandbox"] + /** Proxy variables `execute` must merge into the spawned process's env + * for the loopback shim to be reachable — see `Sandbox.Wrapped.env`. */ + env?: Record } const active = new Map() @@ -675,6 +686,25 @@ export namespace ComputeJobs { } } + /** + * The network policy that actually applies to an SSH transport. + * + * "allowlist" is relaxed to "allow" because that policy is HTTP-only: it + * severs the network namespace and offers one HTTP proxy socket, which ssh + * cannot use at all — it does not read HTTP_PROXY and needs ProxyCommand or + * SOCKS. Applying it to ssh is not bounded egress, it is denial with a + * confusing error, and it buys nothing: the job's command runs on the remote + * machine, so the process being confined is a transport rather than the + * workload. + * + * An explicit "deny" is left alone. That is a user saying no network, not a + * default they never chose, and honouring it is the difference between + * relaxing a default and overriding an instruction. + */ + export function transportNetwork(requested: "deny" | "allowlist" | "allow") { + return requested === "allowlist" ? ("allow" as const) : requested + } + async function launch( job: Job, host: Host | undefined, @@ -683,35 +713,66 @@ export namespace ComputeJobs { ): Promise { const spec = command(job, host) if (host) { + // The ssh CLIENT is what gets wrapped here; the job's command runs on the + // remote machine. Two consequences. + // + // Network containment of this process buys nothing — the code being + // confined is a transport, not the workload — and under "allowlist" it + // actively breaks the feature: that policy severs the network namespace + // and offers one HTTP proxy socket as the only route out, which ssh + // cannot use. It reads HTTP_PROXY not at all and needs ProxyCommand or + // SOCKS. So an allowlist remote job did not fail closed with a useful + // message, it failed with an opaque connection error on the default + // policy. + // + // Filesystem containment still matters and is kept: ssh reads keys and + // can write locally. Only the network dimension is relaxed, and the value + // reported below is the one actually applied, not the one requested — + // reporting "allowlist" for a process running unconfined would be worse + // than the original bug. + const network = transportNetwork(authority.sandbox.network) + const relaxed = { ...authority.sandbox, network } + const egress = await EgressRuntime.egressFor(relaxed) const planned = Sandbox.wrapArgv({ file: spec.argv[0]!, args: spec.argv.slice(1), workspace: authority.writable, unreadable: OpenScience.kernelSensitivePaths(), - options: authority.sandbox, + options: { ...relaxed, egress }, }) + const note = + network === authority.sandbox.network + ? planned.warning + : [ + planned.warning, + "network left unconfined for the ssh transport: the allowlist proxy is HTTP-only and ssh cannot use it. The job's own command runs on the remote host, outside this sandbox either way.", + ] + .filter(Boolean) + .join(" ") return { argv: [planned.file, ...planned.args], sandbox: { requested: authority.sandbox.enabled, enforced: planned.sandboxed, backend: planned.backend, - network: authority.sandbox.network, - warning: planned.warning, + network, + warning: note, }, + env: planned.env, } } await fs.mkdir(logsOf(scope.root), { recursive: true }) await fs.writeFile(exitOf(scope.root, job.id), "", { mode: 0o600 }) const wrapped = `(${job.command}\n); code=$?; printf %s "$code" > ${quote(exitOf(scope.root, job.id))}; exit "$code"` + const egress = await EgressRuntime.egressFor(authority.sandbox) const planned = Sandbox.wrapArgv({ file: Shell.acceptable(), args: ["-lc", wrapped], workspace: authority.writable, extraWritable: [exitOf(scope.root, job.id)], unreadable: OpenScience.kernelSensitivePaths(), - options: authority.sandbox, + options: { ...authority.sandbox, egress }, }) return { argv: [planned.file, ...planned.args], @@ -722,6 +783,7 @@ export namespace ComputeJobs { network: authority.sandbox.network, warning: planned.warning, }, + env: planned.env, } } @@ -730,16 +792,17 @@ export namespace ComputeJobs { cwd: string, authority: ExecutionAuthority.Decision, ): Promise { + const egress = await EgressRuntime.egressFor(authority.sandbox) const planned = Sandbox.wrapArgv({ file: argv[0]!, args: argv.slice(1), workspace: authority.writable, unreadable: OpenScience.kernelSensitivePaths(), - options: authority.sandbox, + options: { ...authority.sandbox, egress }, }) const proc = Bun.spawn([planned.file, ...planned.args], { cwd, - env: await OpenScience.subprocessEnv(process.env), + env: { ...(await OpenScience.subprocessEnv(process.env)), ...(planned.env ?? {}) }, stdin: "ignore", stdout: "pipe", stderr: "ignore", @@ -1103,7 +1166,7 @@ export namespace ComputeJobs { await fs.mkdir(logsOf(scope.root), { recursive: true }) const log = path.join(logsOf(scope.root), `${job.id}.log`) const output = await fs.open(log, "a", 0o600) - const env = await OpenScience.subprocessEnv(process.env) + const env = { ...(await OpenScience.subprocessEnv(process.env)), ...(launch.env ?? {}) } const queued = (await read(scope.root)).find((item) => item.id === job.id) if (queued?.status === "cancelled") { await output.close() @@ -1828,12 +1891,12 @@ export namespace ComputeJobs { args: spec.argv.slice(1), workspace: job.authority.writable, unreadable: OpenScience.kernelSensitivePaths(), - options: job.authority.sandbox, + options: { ...job.authority.sandbox, egress: await EgressRuntime.egressFor(job.authority.sandbox) }, }) - : { file: spec.argv[0]!, args: spec.argv.slice(1) } + : { file: spec.argv[0]!, args: spec.argv.slice(1), env: undefined } const proc = spawn(planned.file, planned.args, { cwd: job.authority?.workspace, - env: await OpenScience.subprocessEnv(process.env), + env: { ...(await OpenScience.subprocessEnv(process.env)), ...(planned.env ?? {}) }, windowsHide: true, stdio: "ignore", }) diff --git a/backend/cli/src/config/config.ts b/backend/cli/src/config/config.ts index 4f7372c5..e8e329b4 100644 --- a/backend/cli/src/config/config.ts +++ b/backend/cli/src/config/config.ts @@ -757,9 +757,15 @@ export namespace Config { "Run local terminals, kernels, and shell commands inside an OS sandbox (macOS Seatbelt / Linux bubblewrap) that confines writes to authorized project roots. Enabled by default.", ), network: z - .enum(["allow", "deny"]) + .enum(["deny", "allowlist", "allow"]) .optional() - .describe("Whether sandboxed commands may reach the network. Default: deny."), + .describe("Whether sandboxed commands may reach the network. Default: allowlist."), + allowHosts: z + .array(z.string()) + .optional() + .describe( + "Extra hosts sandboxed processes may reach when network is 'allowlist'. A leading dot matches subdomains, e.g. '.internal.example.com'.", + ), allowWrite: z .array(z.string()) .optional() @@ -1718,7 +1724,8 @@ export namespace Config { const policy = { ...(base ?? {}), ...(managed ?? {}) } return { enabled: policy.enabled ?? true, - network: policy.network ?? "deny", + network: policy.network ?? "allowlist", + allowHosts: policy.allowHosts ?? [], allowWrite: policy.allowWrite ?? [], onUnavailable: policy.onUnavailable ?? "error", } diff --git a/backend/cli/src/index.ts b/backend/cli/src/index.ts index 09d00e66..1ad6c0f6 100644 --- a/backend/cli/src/index.ts +++ b/backend/cli/src/index.ts @@ -41,6 +41,45 @@ import { SandboxCommand } from "./cli/cmd/sandbox" import { InitCommand, DoctorCommand } from "./cli/onboard" import { OpenScience } from "./openscience" +// Handle the hidden egress shim before any other CLI machinery — yargs +// construction and its global `.middleware` below — is reached. This runs +// inside the sandboxed namespace `bubblewrapArgs` builds, where `/` is +// read-only (the middleware's `Log.init` opens a log file — EROFS) and the +// network is unshared except for the one bind-mounted socket (the +// middleware's `OpenScience.refreshIfStale` is an HTTP fetch — hangs against +// a severed network). The shim only opens a listener and forwards bytes; it +// must never reach either. This is a plain check against argv, before yargs +// (or anything yargs triggers) parses anything, so it cannot regress if the +// middleware grows later — there is no yargs command path to keep in sync. +if (process.argv[2] === "__egress-shim") { + const { Egress } = await import("./sandbox/egress") + const { SHIM_READY_MARKER } = await import("./sandbox/egress-shim-marker") + Egress.serveShim({ port: Number(process.argv[3]), socket: process.argv[4] as string }) + await Bun.write(SHIM_READY_MARKER, "").catch(() => {}) + await new Promise(() => {}) +} + +// Windows containment is applied AT process creation, not by a wrapper +// executable, so the binary launches itself into the AppContainer and execs the +// real command. Same placement and same reasoning as the shim above: a plain +// argv check before yargs parses anything, so no command path has to stay in +// sync, and none of the startup middleware runs inside a token that may not be +// able to reach the network or the user profile. +if (process.argv[2] === "__appcontainer-launch") { + const { AppContainer } = await import("./sandbox/appcontainer") + const rest = process.argv.slice(3) + const split = rest.indexOf("--") + if (split === -1) { + process.stderr.write("openscience: __appcontainer-launch requires -- \n") + process.exit(2) + } + const code = await AppContainer.main(rest[0] as string, rest.slice(split + 1)).catch((error: Error) => { + process.stderr.write(`openscience: ${error.message}\n`) + return 1 + }) + process.exit(code) +} + process.on("unhandledRejection", (e) => { Log.Default.error("rejection", { e: e instanceof Error ? e.message : e, diff --git a/backend/cli/src/openscience/index.ts b/backend/cli/src/openscience/index.ts index 870d9b55..7393bcc3 100644 --- a/backend/cli/src/openscience/index.ts +++ b/backend/cli/src/openscience/index.ts @@ -121,6 +121,12 @@ const KERNEL_RUNTIME_KEYS = new Set([ "WINDIR", "PATHEXT", "COMSPEC", + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", ]) const SAFE_SYNCED_KEYS = new Set([ ...BYOK_LLM_ENV_KEYS, diff --git a/backend/cli/src/package/environment.ts b/backend/cli/src/package/environment.ts new file mode 100644 index 00000000..14153922 --- /dev/null +++ b/backend/cli/src/package/environment.ts @@ -0,0 +1,228 @@ +import fs from "fs/promises" +import path from "path" +import z from "zod" +import { Global } from "../global" + +/** + * Environment state. No process spawning lives here — the installer owns that. + * + * The manifest is the source of truth and the directory is derived, therefore a + * cache. That is why they sit in different roots: `Global.Path.cache` may be + * cleared by the user or a cleaner at any time, and an environment must be + * rebuildable from its manifest afterwards. Putting the manifest inside the + * directory would make a cache clean an unrecoverable data loss. + */ +export namespace Environment { + export const Language = z.enum(["python", "r"]) + export type Language = z.infer + + export const Record = z.object({ + name: z.string(), + language: Language, + /** Only what was explicitly asked for, never the resolved closure. */ + requested: z.array(z.string()).default([]), + /** Resolved name → version, as the installer reported it after the fact. */ + installed: z.record(z.string(), z.string()).default({}), + /** Size of the resolved closure, reported as a number rather than listed. */ + total: z.number().int().nonnegative().default(0), + createdAt: z.number(), + updatedAt: z.number(), + }) + export type Record = z.infer + + export function manifest(projectID: string, name: string) { + return path.join(Global.Path.data, "envs", projectID, `${name}.json`) + } + + export function directory(projectID: string, name: string) { + return path.join(Global.Path.cache, "envs", projectID, name) + } + + export async function read(projectID: string, name: string) { + const file = Bun.file(manifest(projectID, name)) + if (!(await file.exists())) return undefined + const parsed = Record.safeParse(await file.json().catch(() => undefined)) + return parsed.success ? parsed.data : undefined + } + + /** + * Write the manifest, validating first. + * + * The parse is not ceremony. `JSON.stringify` drops keys whose value is + * `undefined`, so a caller that omits one — a tool invoked without zod + * having applied its defaults, say — writes a manifest that `read` then + * rejects. The result is an environment that exists on disk, holds installed + * packages, and is invisible to the inventory: silent, and indistinguishable + * from "never created" at every call site. Validating here turns that into a + * loud failure at the moment of the mistake. + */ + export async function write(projectID: string, value: Record) { + const parsed = Record.safeParse(value) + if (!parsed.success) { + throw new Error( + `Refusing to write an unreadable environment manifest for ${value.name}: ${parsed.error.issues + .map((i) => `${i.path.join(".") || "(root)"} ${i.message}`) + .join("; ")}`, + ) + } + const file = manifest(projectID, parsed.data.name) + await fs.mkdir(path.dirname(file), { recursive: true }) + await Bun.write(file, JSON.stringify(parsed.data, null, 2)) + } + + /** Every environment for a project. A manifest that fails to parse is skipped + * rather than thrown on: one hand-edited or half-written file must not make + * every other environment in the project invisible. */ + export async function list(projectID: string) { + const dir = path.join(Global.Path.data, "envs", projectID) + const names = await fs.readdir(dir).catch(() => [] as string[]) + const values = await Promise.all( + names.filter((n) => n.endsWith(".json")).map((n) => read(projectID, n.slice(0, -".json".length))), + ) + return values.filter((v): v is Record => Boolean(v)) + } + + /** + * Purely additive means every package present before is present after at the + * same version. + * + * Additive changes leave a live kernel correct: a new module imports on first + * use. Any removal, downgrade or version change does not — a module already + * loaded into the interpreter stays at the old version in memory while the + * files on disk say otherwise, which is worse than an obvious failure because + * it is silent. That asymmetry is the whole reason this function exists + * rather than restarting on every install. + */ + export function additive(before: Record["installed"], after: Record["installed"]) { + return Object.entries(before).every(([name, version]) => after[name] === version) + } + + /** + * A persisted record that an installer is working on this environment, with + * enough identity to tell "still running" from "died mid-install" after a CLI + * restart. pid alone is not enough — pids are reused — so the platform start + * token rides along, the same guard `science/kernel/process.ts` already + * applies to kernels. The token is optional because it does not exist on + * every platform. + * + * Under `Global.Path.state`, not `data`: this is per-machine liveness, not + * something to survive a restore onto another machine. + */ + const Claim = z.object({ + pid: z.number().int(), + token: z.string().optional(), + startedAt: z.number(), + /** Set when the install finished and FAILED. A claim carrying this is no + * longer about liveness — the process is gone and we know why. */ + error: z.string().optional(), + }) + + export function claimPath(projectID: string, name: string) { + return path.join(Global.Path.state, "envs", projectID, `${name}.claim.json`) + } + + export async function claim(projectID: string, name: string, pid: number, value?: string) { + const file = claimPath(projectID, name) + await fs.mkdir(path.dirname(file), { recursive: true }) + await Bun.write(file, JSON.stringify({ pid, token: value, startedAt: Date.now() })) + } + + export async function release(projectID: string, name: string) { + await fs.rm(claimPath(projectID, name), { force: true }) + } + + /** + * Record that a detached install failed. + * + * Without this a `wait: false` failure vanished: the error was caught and + * discarded, no manifest was written, the claim was released cleanly, and the + * agent had been told "started installing" with no way to ever learn + * otherwise. Replacing the claim rather than deleting it keeps one file as + * the single place an unfinished install is described, whatever became of it. + */ + export async function fail(projectID: string, name: string, message: string) { + const file = claimPath(projectID, name) + await fs.mkdir(path.dirname(file), { recursive: true }) + await Bun.write(file, JSON.stringify({ pid: process.pid, startedAt: Date.now(), error: message.slice(0, 2000) })) + } + + /** + * Resolve every outstanding claim for a project. + * + * An install that cannot be proven still running is `unknown`, never `fine`: + * pip has no transactions, so an interrupted one may have left a partial + * tree, and silently trusting it turns into a mystery ImportError several + * turns later. "Cannot prove" means the process is gone, or a token that was + * captured no longer matches — NOT merely that no token exists, which is the + * ordinary case on Windows. + * + * Resolved claims are deleted so a second call does not re-report them; a + * still-running one is left in place, because it is still true. + */ + export async function reconcile(projectID: string) { + const { KernelProcessIdentity } = await import("../science/kernel/process") + const dir = path.join(Global.Path.state, "envs", projectID) + const names = await fs.readdir(dir).catch(() => [] as string[]) + const out: { name: string; outcome: "running" | "unknown" | "failed"; message?: string }[] = [] + for (const file of names.filter((n) => n.endsWith(".claim.json"))) { + const name = file.slice(0, -".claim.json".length) + const parsed = Claim.safeParse( + await Bun.file(path.join(dir, file)) + .json() + .catch(() => undefined), + ) + if (!parsed.success) { + await fs.rm(path.join(dir, file), { force: true }) + out.push({ name, outcome: "unknown" }) + continue + } + // A recorded failure is not a liveness question — the process is gone and + // the reason is known, so report it and clear it. + if (parsed.data.error) { + await fs.rm(path.join(dir, file), { force: true }) + out.push({ name, outcome: "failed", message: parsed.data.error }) + continue + } + const alive = KernelProcessIdentity.running(parsed.data.pid, parsed.data.token) + if (!alive) await fs.rm(path.join(dir, file), { force: true }) + out.push({ name, outcome: alive ? "running" : "unknown" }) + } + return out + } + + const held = new Map>() + + const slot = (projectID: string, name: string) => `${projectID} ${name}` + + /** True while something holds this environment's lock. */ + export function busy(projectID: string, name: string) { + return held.has(slot(projectID, name)) + } + + /** + * Serialise work per environment. Other environments stay fully usable — the + * lock is per-env precisely so one long install does not stop every kernel in + * the project. + * + * The chain is built from the previous entry rather than awaited in place, so + * a caller arriving mid-install queues instead of racing. `previous.then(fn, + * fn)` runs the next body whether the one before it resolved or rejected: a + * failed install must not cancel the work queued behind it. The slot is + * cleared only if it is still ours, so a later waiter that replaced it is not + * evicted — and it is cleared in a `finally`, because a lock that survives a + * throw would brick the environment for the process lifetime, which is the + * latching bug the egress runtime shipped with. + */ + export async function lock(projectID: string, name: string, fn: () => Promise): Promise { + const id = slot(projectID, name) + const previous = held.get(id) ?? Promise.resolve() + const run = previous.then(fn, fn) + const tracked = run.catch(() => undefined) + held.set(id, tracked) + try { + return await run + } finally { + if (held.get(id) === tracked) held.delete(id) + } + } +} diff --git a/backend/cli/src/package/installer-r.ts b/backend/cli/src/package/installer-r.ts new file mode 100644 index 00000000..0b96fd8f --- /dev/null +++ b/backend/cli/src/package/installer-r.ts @@ -0,0 +1,152 @@ +import fs from "fs/promises" +import { Config } from "../config/config" +import { EgressRuntime } from "../sandbox/egress-runtime" +import { Sandbox } from "../sandbox/sandbox" +import { Installer } from "./installer" + +/** + * The R backend, and the simpler one by a wide margin. + * + * There is no ladder to probe and no pip to bootstrap: `install.packages` is + * part of base R, and the binding is a library path (`R_LIBS_USER`) rather than + * a per-environment interpreter. `cran.r-project.org` is already in + * `Egress.DEFAULT_RULES`, so the allowlist needs no change either. + * + * Runs under the same sandbox as the Python installer, for the same reason: the + * install is not more privileged than the kernel that will use it. + */ +export namespace InstallerR { + /** + * The package index. A named constant rather than a literal inside the + * generated R script: it is the one value that decides where packages come + * from, and a test can assert it by equality instead of grepping this file + * for a domain — which reads to a static analyser as an incomplete URL check. + * + * Already covered by `Egress.DEFAULT_RULES`, so changing it means changing + * the allowlist too. + */ + export const REPO = "https://cran.r-project.org" + + /** PEP 503-style normalisation is wrong for CRAN — R package names are + * case-sensitive and `.` is meaningful (`data.table`). Compared verbatim. */ + const key = (value: string) => value.trim() + + export async function create(directory: string) { + await fs.mkdir(Installer.rlibrary(directory), { recursive: true }) + } + + async function confined(directory: string, argv: string[]) { + const policy = await Config.trustedSandbox() + const egress = await EgressRuntime.egressFor(policy) + return Sandbox.wrapArgv({ + file: argv[0]!, + args: argv.slice(1), + workspace: [directory], + options: { ...policy, egress }, + }) + } + + /** `Rscript -e` with the library pinned to the environment. `lib` is passed + * explicitly as well as through `R_LIBS_USER`, because `install.packages` + * otherwise picks the first writable entry of `.libPaths()` — which on a + * machine with a user library already set would be the wrong directory and + * would leak this environment's packages into every other project. */ + export async function install(input: { directory: string; packages: string[]; signal?: AbortSignal }) { + const lib = Installer.rlibrary(input.directory) + await create(input.directory) + const names = input.packages.map((p) => JSON.stringify(key(p))).join(", ") + const script = [ + `lib <- ${JSON.stringify(lib)}`, + `.libPaths(c(lib, .libPaths()))`, + `install.packages(c(${names}), lib = lib, repos = ${JSON.stringify(REPO)}, quiet = TRUE)`, + // install.packages() signals failure with a warning, not a non-zero exit, + // so a missing package would otherwise look like success. + `missing <- setdiff(c(${names}), rownames(installed.packages(lib.loc = lib)))`, + `if (length(missing)) { cat("FAILED:", paste(missing, collapse = ", "), "\\n"); quit(status = 1) }`, + ].join("\n") + const spec = await confined(input.directory, ["Rscript", "-e", script]) + const proc = Bun.spawn([spec.file, ...(spec.args ?? [])], { + env: { ...process.env, ...spec.env, R_LIBS_USER: lib }, + stdout: "pipe", + stderr: "pipe", + signal: input.signal, + }) + const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]) + await proc.exited + return { ok: proc.exitCode === 0, log: [out, err].filter(Boolean).join("\n") } + } + + /** name → version for everything in the environment's library. */ + export async function freeze(directory: string) { + const lib = Installer.rlibrary(directory) + const script = [ + `ip <- installed.packages(lib.loc = ${JSON.stringify(lib)})`, + `if (nrow(ip)) cat(paste(rownames(ip), ip[, "Version"], sep = "\\t", collapse = "\\n"))`, + ].join("\n") + const proc = Bun.spawn(["Rscript", "-e", script], { + env: { ...process.env, R_LIBS_USER: lib }, + stdout: "pipe", + stderr: "pipe", + }) + const text = await new Response(proc.stdout).text() + await proc.exited + const out: Record = {} + for (const line of text.split("\n")) { + const [name, version] = line.split("\t") + if (name && version) out[name.trim()] = version.trim() + } + return out + } + + /** Every package an R kernel bound to this environment can load, system + * libraries included — the same distinction `Installer.resolved` draws for + * Python, and needed for the same reason: the restart decision is about what + * the kernel sees, not what the environment owns. */ + export async function resolved(directory: string) { + const lib = Installer.rlibrary(directory) + const script = [ + `ip <- installed.packages()`, + `if (nrow(ip)) cat(paste(rownames(ip), ip[, "Version"], sep = "\t", collapse = "\n"))`, + ].join("\n") + const proc = Bun.spawn(["Rscript", "-e", script], { + env: { ...process.env, R_LIBS_USER: lib }, + stdout: "pipe", + stderr: "pipe", + }) + const text = await new Response(proc.stdout).text() + await proc.exited + const out: Record = {} + for (const line of text.split("\n")) { + const [name, version] = line.split("\t") + if (name && version) out[name.trim()] = version.trim() + } + return out + } + + export async function verify(directory: string, packages: string[]) { + const frozen = await freeze(directory) + const out: Record = {} + for (const name of packages) { + const version = frozen[key(name)] + if (version) out[name] = version + } + return out + } + + /** CRAN's failure text, reduced to the actionable line. Unlike pip there is + * no wheels-only concept, so the two surfaces are "no such package" and a + * compilation failure naming a system header. */ + export function explain(log: string) { + const missing = log.match(/^FAILED:\s*(.+)$/m) + const unavailable = log.match(/package ['‘]([^'’]+)['’] is not available/) + if (unavailable) { + return `CRAN has no package named ${unavailable[1]} for this R version. Check the spelling, or whether it lives on Bioconductor rather than CRAN.` + } + const fatal = log.match(/^\s*fatal error:\s*(.+)$/m) + if (fatal) { + return `An R package failed to compile: ${fatal[1]!.trim()} A sandboxed install cannot add system libraries — prefer a package that ships a binary, or install the system dependency outside OpenScience.` + } + if (missing) return `These packages did not install: ${missing[1]!.trim()}\n${log.trim()}` + return log.trim() + } +} diff --git a/backend/cli/src/package/installer.ts b/backend/cli/src/package/installer.ts new file mode 100644 index 00000000..57b1cfa0 --- /dev/null +++ b/backend/cli/src/package/installer.ts @@ -0,0 +1,416 @@ +import fs from "fs/promises" +import path from "path" +import { Config } from "../config/config" +import { Global } from "../global" +import { EgressRuntime } from "../sandbox/egress-runtime" +import { Sandbox } from "../sandbox/sandbox" + +/** + * The installer ladder and the sandboxed run. + * + * The install runs in the SAME sandbox as the kernel, not a second more + * permissive one. Earlier drafts specified a separate network-enabled install + * sandbox because the kernel's was network-denied; the allowlist proxy removed + * that asymmetry, so the only differences are what is writable — the + * environment directory and a package cache inside it. + * + * uv is a fast path, never a requirement: `python3 -m venv` bootstraps pip + * offline from the interpreter's bundled `ensurepip` wheel, verified inside + * `--unshare-net` on a host whose `python3` has no pip at all. Never + * auto-download uv — probe, use if present, throw a remedy if not. House + * precedent: `compute/modal/volume.ts:112-116`. + */ +export namespace Installer { + export type Tool = { kind: "existing" | "uv" | "venv"; binary: string } + + const bindir = process.platform === "win32" ? "Scripts" : "bin" + const exe = process.platform === "win32" ? ".exe" : "" + + /** PEP 503 normalisation, matching `Requirement.parse`. Both sides of an + * additivity comparison have to agree or an upgrade looks like an addition. */ + const normalise = (value: string) => value.replace(/[-_.]+/g, "-").toLowerCase() + + /** The environment's own interpreter — what kernels bind to, and what every + * install and verification runs through. */ + export function interpreter(directory: string) { + return path.join(directory, bindir, `python${exe}`) + } + + /** + * The environment's R library directory — R's equivalent of the interpreter + * binding, since R has no per-environment binary to point at. Reached through + * `R_LIBS_USER`, which is already in the kernel env allowlist. + * + * Kept beside `interpreter` rather than in the R installer so both language + * backends derive their paths from one place; a kernel needs this before any + * R install has ever run. + */ + export function rlibrary(directory: string) { + return path.join(directory, "rlibs") + } + + /** + * An interpreter on PATH that actually runs. + * + * `Bun.which` alone is not enough, and Windows is where that bites. A default + * install has `python3.exe` and `python.exe` in `WindowsApps` as App + * Execution Aliases: zero-byte reparse points that open the Microsoft Store + * instead of an interpreter. `which` finds them, `python3 -m venv ` + * appears to do something, and the environment is then created without an + * interpreter inside it. Measured on a real Windows machine: every install + * failed with `Executable not found in $PATH` naming + * `...\envs\\default\Scripts\python.exe`, with nothing + * explaining why the environment was empty. + * + * `findPython` in the notebook tool has always verified with `--version`; + * this path had drifted from it. Same check, same reason. + */ + const which = (name: string) => { + const found = Bun.which(name) + if (!found) return undefined + const proc = Bun.spawnSync([found, "--version"], { stdout: "ignore", stderr: "ignore" }) + return proc.exitCode === 0 ? found : undefined + } + + /** + * The ladder, in order: an existing environment wins over any tool, then uv, + * then venv, then a remedy. + * + * `available` exists so the uv/venv branches are testable on a machine that + * has only one of them; real callers omit it and get a live probe. + */ + export async function probe(directory: string, available?: { uv?: string; python?: string }): Promise { + const existing = await Bun.file(interpreter(directory)) + .exists() + .catch(() => false) + if (existing) return { kind: "existing", binary: interpreter(directory) } + + const uv = available ? available.uv : (Bun.which("uv") ?? undefined) + if (uv) return { kind: "uv", binary: uv } + + const python = available ? available.python : (which("python3") ?? which("python")) + if (python) return { kind: "venv", binary: python } + + throw new Error( + [ + "No way to create a Python environment on this machine.", + "Install one of:", + " - the venv module: `apt install python3-venv` on Debian/Ubuntu (most other distributions ship it with python3)", + " - uv: https://docs.astral.sh/uv/getting-started/installation/", + "OpenScience never downloads either automatically.", + ].join("\n"), + ) + } + + /** + * Create the environment. A no-op when it already exists — rebuilding would + * silently discard everything installed into it. + * + * `--seed` on the uv branch is load-bearing, not a nicety. `python3 -m venv` + * bootstraps pip from `ensurepip`; `uv venv` deliberately does not, and + * `install()` shells out to `python -m pip` regardless of who created the + * environment. Without it the uv branch produces an environment the + * installer cannot use at all — measured as `No module named pip` from a + * venv that looked perfectly healthy from outside the sandbox. + * + * Seeding rather than adding a second `uv pip install` path keeps one + * install code path to test and maintain, and leaves the environment usable + * by hand. The cost is a few hundred milliseconds at creation only. + */ + export async function create(directory: string, tool: Tool) { + if (tool.kind === "existing") return + await fs.mkdir(path.dirname(directory), { recursive: true }) + // `--system-site-packages` is not a convenience, it repairs a cliff. + // + // A kernel binds to the managed environment as soon as one exists, and + // falls back to the host interpreter while it does not. So without this, + // the FIRST install of anything silently removed every host package from + // every kernel in the project: install `tqdm`, lose `numpy`. Measured in + // real use — the notebook tool advertises numpy/pandas/scipy/matplotlib as + // pre-imported, and they vanished the moment an environment appeared. + // + // Inheriting is strictly a superset of the behaviour kernels had before + // managed environments existed, when they simply WERE the host + // interpreter, so it exposes nothing new: host site-packages was already + // readable under `--ro-bind / /`. The environment's own packages still take + // precedence, so installing a newer version shadows the host's. + // + // The cost is that the environment is not hermetic. A hermetic mode is a + // reasonable future flag; it is the wrong default for a tool whose users + // expect the scientific stack to be there. + const argv = + tool.kind === "uv" + ? [tool.binary, "venv", "--seed", "--system-site-packages", directory] + : [tool.binary, "-m", "venv", "--system-site-packages", directory] + const proc = Bun.spawn(argv, { stdout: "pipe", stderr: "pipe" }) + const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]) + await proc.exited + if (proc.exitCode !== 0) throw new Error(`Could not create the environment at ${directory}.\n${err || out}`) + // Exit code 0 is not proof. A Windows App Execution Alias standing in for + // python exits cleanly having created nothing, and the failure then + // surfaced much later as "Executable not found in $PATH" from the install + // step, naming a path with no hint as to why it was missing. Assert the + // thing the rest of this module depends on, at the moment it should exist. + if (!(await Bun.file(interpreter(directory)).exists())) { + throw new Error( + [ + `Creating the environment at ${directory} reported success but produced no interpreter at ${interpreter(directory)}.`, + process.platform === "win32" + ? "On Windows this usually means PATH resolves python to a Microsoft Store App Execution Alias rather than a real interpreter. Install Python from python.org, or turn the alias off under Settings > Apps > Advanced app settings > App execution aliases." + : "Install a working python3 with the venv module, or install uv.", + (err || out).trim(), + ] + .filter(Boolean) + .join("\n"), + ) + } + } + + /** + * The wheel cache, shared by every environment on the machine. + * + * Deliberately NOT inside the environment directory, which is where it lived + * first. A per-environment cache means every new environment re-downloads + * everything: measured at 34 MB and a full download for scipy alone, in a + * second environment that had just been populated in the first — and the + * packages that make this hurt are the large ones, where it is hundreds of + * megabytes per environment. + * + * It is our own cache directory rather than user data, so sharing it across + * projects costs nothing in isolation terms. pip's cache is content-addressed + * and safe for concurrent readers and writers, which matters because the + * per-environment lock does not serialise installs into DIFFERENT + * environments. + */ + const shared = () => path.join(Global.Path.cache, "pip") + + /** Sandboxed argv for a command run against the environment: the same policy + * the kernel gets, plus write access to the environment directory and the + * shared wheel cache. */ + async function confined(directory: string, argv: string[]) { + const policy = await Config.trustedSandbox() + const egress = await EgressRuntime.egressFor(policy) + return Sandbox.wrapArgv({ + file: argv[0]!, + args: argv.slice(1), + workspace: [directory, shared()], + options: { ...policy, egress }, + }) + } + + /** + * The most recent line of pip output worth showing a human. + * + * pip reports phase and size continuously — "Collecting torch", "Downloading + * torch-…whl (906.4 MB)", "Installing collected packages: …" — and all of it + * used to be buffered and discarded unless the install failed. A pytorch + * install sat behind an unchanging ellipsis for 1m37s while that ran. + * + * Progress-bar redraws and continuation lines are skipped: they are noise at + * one line of visible status, and a bar rendered to a pipe is mostly control + * characters anyway. + */ + const progressLine = (chunk: string) => { + const lines = chunk + .split(/\r?\n/) + .map((l) => l.trim()) + .filter((l) => l && !/^[━╸\-=|/\\ ]*$/.test(l) && !l.startsWith("|")) + return lines.at(-1) + } + + export async function install(input: { + directory: string + packages: string[] + index: string + source: boolean + signal?: AbortSignal + /** Called with a short status as pip reports it. */ + onProgress?: (status: string) => void + }) { + // Two different directories with two different lifetimes. The wheel cache is + // shared across environments so a package is downloaded once per machine; + // the scratch directory pip unpacks into stays environment-local, because it + // is throwaway and sharing it would let concurrent installs collide. + const cache = shared() + const scratch = path.join(input.directory, ".tmp") + await fs.mkdir(cache, { recursive: true }) + await fs.mkdir(scratch, { recursive: true }) + // Wheels-only is a speed and reliability default, NOT a security boundary: + // if bwrap contains agent Python at import time it contains setup.py at + // install time. + const policy = input.source ? [] : ["--only-binary", ":all:"] + const argv = [ + interpreter(input.directory), + "-m", + "pip", + "install", + "--disable-pip-version-check", + ...policy, + ...(input.index ? ["--index-url", input.index] : []), + ...input.packages, + ] + const spec = await confined(input.directory, argv) + const proc = Bun.spawn([spec.file, ...(spec.args ?? [])], { + env: { ...process.env, ...spec.env, PIP_CACHE_DIR: cache, TMPDIR: scratch }, + stdout: "pipe", + stderr: "pipe", + signal: input.signal, + }) + // Drained as it arrives rather than awaited whole, so a caller can report + // progress. The full text is still accumulated: `explain()` needs the + // entire log to find the `fatal error:` line, which is rarely last. + const drain = async (stream: ReadableStream, report: boolean) => { + const reader = stream.getReader() + const decoder = new TextDecoder() + let text = "" + while (true) { + const { done, value } = await reader.read() + if (done) break + const piece = decoder.decode(value, { stream: true }) + text += piece + if (!report || !input.onProgress) continue + const status = progressLine(piece) + if (status) input.onProgress(status) + } + return text + } + // pip writes its progress to stdout and its diagnostics to stderr; only the + // former is worth surfacing as status. + const [out, err] = await Promise.all([drain(proc.stdout, true), drain(proc.stderr, false)]) + await proc.exited + return { ok: proc.exitCode === 0, log: [out, err].filter(Boolean).join("\n") } + } + + /** name → version for everything resolved into the environment, names PEP 503 + * normalised so they compare against parsed requirements. */ + export async function freeze(directory: string) { + // `--local` matters now that environments inherit system site-packages: + // without it this reports every host package too, which would make `total` + // meaningless, bury the requested names in the agent's inventory, and turn + // `additive()` into a comparison against the machine rather than against + // the environment. What this environment OWNS is the question being asked. + const proc = Bun.spawn([interpreter(directory), "-m", "pip", "list", "--local", "--format=json"], { + stdout: "pipe", + stderr: "pipe", + }) + const text = await new Response(proc.stdout).text() + await proc.exited + const parsed = (() => { + try { + return JSON.parse(text) as { name: string; version: string }[] + } catch { + return [] + } + })() + return Object.fromEntries(parsed.map((p) => [normalise(p.name), p.version])) + } + + /** + * Every package the environment's interpreter can import, inherited ones + * included — what a KERNEL bound to this environment actually sees. + * + * Distinct from `freeze()` on purpose, and the distinction is load-bearing. + * `freeze()` answers "what does this environment own", which is the right + * question for the manifest. The restart decision asks something else: has + * what the kernel can import changed underneath it? Comparing owned-sets got + * that wrong the moment environments began inheriting system site-packages — + * requesting the version the host already provides installs nothing locally, + * so the package is absent from the "before" snapshot, and the next version + * then reads as an ADDITION rather than a change. Measured on CI: + * `six==1.16.0` then `six==1.17.0` reported additive, so kernels holding a + * stale `six` in memory were never restarted — exactly the silent staleness + * the rule exists to prevent. + */ + export async function resolved(directory: string) { + const proc = Bun.spawn([interpreter(directory), "-m", "pip", "list", "--format=json"], { + stdout: "pipe", + stderr: "pipe", + }) + const text = await new Response(proc.stdout).text() + await proc.exited + const parsed = (() => { + try { + return JSON.parse(text) as { name: string; version: string }[] + } catch { + return [] + } + })() + return Object.fromEntries(parsed.map((p) => [normalise(p.name), p.version])) + } + + /** + * Report the version of each requested name **as the environment's own + * interpreter resolves it**, whether it lives in the environment or is + * inherited from the host. + * + * Asked of the interpreter rather than of `freeze()`, which lists only what + * the environment owns. Since environments inherit system site-packages, pip + * treats a host-provided package as already satisfied and installs nothing — + * so a `freeze`-based answer reported "(nothing reported)" for a request that + * is, from the user's seat, perfectly satisfied. The question worth answering + * is "can the kernel use it, and at what version", and only the interpreter + * can answer that. + * + * `importlib.metadata` rather than a real import: it reads distribution + * metadata, so it needs no heavy import, triggers no import side effects, and + * handles name normalisation itself. It still catches an installer that + * exited 0 without producing anything usable, which is the point. + */ + export async function verify(directory: string, packages: string[]) { + const script = [ + "import json, sys", + "from importlib.metadata import version, PackageNotFoundError", + "out = {}", + "for name in json.loads(sys.argv[1]):", + " try:", + " out[name] = version(name)", + " except PackageNotFoundError:", + " pass", + "print(json.dumps(out))", + ].join("\n") + const proc = Bun.spawn([interpreter(directory), "-c", script, JSON.stringify(packages)], { + stdout: "pipe", + stderr: "pipe", + }) + const text = await new Response(proc.stdout).text() + await proc.exited + try { + return JSON.parse(text) as Record + } catch { + return {} + } + } + + /** + * Turn a pip log into something a reader can act on. + * + * Two surfaces matter. The wheels-only rejection reads as "no such package" + * and means "no wheel under this policy". A build failure's summary line + * names the package, but the `fatal error:` line above it names the missing + * system header — which usually means the install is unachievable in a + * sandbox and a pure-Python alternative is the real answer. + * + * An unrecognised log passes through untouched. Inventing a diagnosis for a + * failure mode nobody anticipated is worse than showing the log. + */ + export function explain(log: string) { + const wheels = log.match(/Could not find a version that satisfies the requirement (\S+)[^\n]*from versions: none/) + if (wheels) { + return [ + `No wheel is published for ${wheels[1]} under the current wheels-only policy.`, + `This is not "no such package" — it may exist only as a source distribution.`, + `Retry with source builds enabled if a compiler and headers are available.`, + ].join(" ") + } + const fatal = log.match(/^\s*fatal error:\s*(.+)$/m) + const failed = log.match(/Failed building wheel for (\S+)/) + if (fatal) { + return [ + failed ? `Building ${failed[1]} failed.` : "A wheel build failed.", + `The cause is a missing system dependency: ${fatal[1]!.trim()}`, + `A sandboxed install cannot add system packages — prefer a pure-Python alternative, or a package that publishes wheels.`, + ].join(" ") + } + return log.trim() + } +} diff --git a/backend/cli/src/package/prompt.ts b/backend/cli/src/package/prompt.ts new file mode 100644 index 00000000..eaab328f --- /dev/null +++ b/backend/cli/src/package/prompt.ts @@ -0,0 +1,148 @@ +import z from "zod" +// Aliased: this namespace exports its own `Environment` (the render schema), +// which would shadow the store inside every function body here. +import { Environment as Store } from "./environment" + +/** + * Capability contract for governed package installation. + * + * Modelled on `compute/prompt.ts`. The load-bearing mechanism there is not the + * skill override — it is `SystemPrompt.compute()`, injected unconditionally at + * `session/prompt.ts:863` on every request for every agent. That is what makes + * a contract hold across 293 skills, their reference files, and third-party + * skills cloned from GitHub that this repo cannot edit: it pre-empts rather + * than corrects, and it names the specific wrong commands rather than + * gesturing at a policy. + * + * Wire the same way — add `...(await SystemPrompt.packages())` to the system + * array. A skill-level override is deliberately NOT provided: it only reaches + * the front page of a skill, never its references, and the block below already + * covers what such an override would say. + */ +export namespace PackagePrompt { + export const Environment = z.object({ + name: z.string(), + language: z.enum(["python", "r"]), + /** + * Only what was explicitly asked for, never the resolved closure. + * + * A real environment listing is dominated by transitive and native + * dependencies — a reference implementation's shared env reports 168 + * entries, most of them `libgcc`, `harfbuzz`, `xorg-libx11`, `qt6-main`, + * with the importable Python packages a minority. Rendering that into every + * request would bury the contract in font libraries and teach the agent + * nothing it can act on. + */ + requested: z.array(z.string()).default([]), + /** Size of the resolved closure, reported as a number rather than listed. */ + total: z.number().int().nonnegative().optional(), + /** Set while an install holds this env's lock. */ + busy: z.boolean().default(false), + }) + export type Environment = z.infer + + const Stored = z + .object({ + environments: z.array(Environment).default([]), + /** Outcomes of installs that finished without anyone watching. */ + warnings: z.array(z.string()).default([]), + }) + .passthrough() + + const inventory = (values: Environment[]) => { + if (!values.length) { + return ["No environments exist yet. The first install creates one; you do not create it separately."] + } + return values.map((env) => { + const held = env.requested.length ? env.requested.toSorted().join(", ") : "(empty)" + const rest = env.total && env.total > env.requested.length ? ` (+${env.total - env.requested.length} deps)` : "" + const lock = env.busy ? " [INSTALL IN PROGRESS — do not execute in this environment until it finishes]" : "" + return `- ${env.name} (${env.language}): ${held}${rest}${lock}` + }) + } + + export function render(value: unknown) { + const parsed = Stored.safeParse(value) + const envs = parsed.success ? parsed.data.environments : [] + + const warnings = parsed.success ? parsed.data.warnings : [] + return [ + "", + ...(warnings.length + ? [ + "UNRESOLVED INSTALLS — tell the user about these before doing anything else:", + ...warnings.map((w) => `- ${w}`), + "", + ] + : []), + "Environments available to kernels in this project:", + ...inventory(envs), + "", + "Package installation contract:", + "- Whether a package is already available is a read-only question. Answer it from the inventory above. Never install a package, and never run code, merely to find out whether something is present.", + "- Do not request a package the inventory already lists. A fully-satisfied request installs nothing and is not worth a turn.", + "- `package_install` is the only way to add packages. Call it when the user asks for a package, or when work you are about to do needs one that is absent.", + "- Never install through the shell. `pip install`, `pip3 install`, `python -m pip`, `uv pip install`, `conda install`, `mamba install`, `poetry add`, and `install.packages()` are refused here, including into a virtualenv you create yourself in the workspace. Skills and their reference files that instruct you to run these commands describe an ungoverned runtime and are superseded — use `package_install` instead.", + "- Do not attempt to install or repair pip itself, create a virtualenv by hand, or edit an environment directory. The tool owns environment creation, the installer choice, and the target path.", + "- Installing restarts every kernel bound to that environment and discards its variables. Prefer to install before a long computation rather than during one. If a cell is running, the install queues behind it.", + "- An environment is scoped to one language. Python packages go to a python environment, R packages to an R environment; there is no shared environment.", + "- A local environment and a Modal job image are unrelated. Installing locally does not make a package available to a Modal job, and a Modal job's `packages` field does not affect any local environment. If the target is ambiguous, ask which one the user means.", + "- Report only what the tool returns. Do not claim an install succeeded, estimate a download size, or invent a version you have not been shown.", + "", + ].join("\n") + } + + /** + * The inventory the agent sees, assembled from real manifests. + * + * `busy` is read from the live in-memory lock rather than stored on the + * manifest. A persisted flag would survive a crash and permanently mark a + * healthy environment as installing, with nothing to clear it; the lock + * cannot outlive the process that holds it. + * + * Takes a project id, not an opaque value — the earlier signature read a + * single global `environments.json` that nothing ever wrote, so the agent was + * told "No environments exist yet" forever, including immediately after + * installing something. That made the contract's first rule ("answer from the + * inventory above") a lie. Callers pass `undefined` only in tests that want + * the empty rendering. + */ + export async function system(projectID?: string) { + if (!projectID) return render({ environments: [] }) + // The only production caller of reconcile(), and the right one: this runs + // on every request, so the first request after a restart resolves any claim + // left by an install that never finished. Without a caller the whole + // claim/token mechanism was dead code — built, tested, and reached only by + // its own tests. + // + // Cheap enough to do here: a readdir of a directory that is empty except + // when an install is in flight or one ended badly, and it self-clears, so + // the next request finds nothing. + const outcomes = await Store.reconcile(projectID).catch(() => []) + const values = await Store.list(projectID) + return render({ + environments: values.map((env) => ({ + name: env.name, + language: env.language, + requested: env.requested, + total: env.total, + busy: Store.busy(projectID, env.name), + })), + // An environment that only ever existed as a failed install has no + // manifest, so warnings are carried separately rather than attached to + // the inventory rows — otherwise the one case worth reporting is the one + // case with nowhere to report it. + warnings: outcomes.flatMap((o) => { + if (o.outcome === "failed") { + return [`Install into ${o.name} FAILED and nothing was landed: ${o.message ?? "no detail recorded"}`] + } + if (o.outcome === "unknown") { + return [ + `An install into ${o.name} was interrupted and its outcome is unknown. The environment may be incomplete — verify before relying on it, and re-install if in doubt.`, + ] + } + return [] + }), + }) + } +} diff --git a/backend/cli/src/package/refuse.ts b/backend/cli/src/package/refuse.ts new file mode 100644 index 00000000..d9a218ae --- /dev/null +++ b/backend/cli/src/package/refuse.ts @@ -0,0 +1,102 @@ +/** + * Shell-side refusal of package installers. + * + * A contract boundary, not a security boundary. The same allowlisted egress + * that lets `package_install` reach pypi lets a determined agent fetch a wheel + * by hand, and nothing here stops that. What this buys is that the *normal* + * path — every skill's `pip install` line, every reference file this repo + * cannot edit — arrives at the approval card instead of quietly succeeding. + * + * It became load-bearing only recently. Before the allowlist proxy, a shell + * `pip install` died at DNS, so the contract held by accident; measured on + * `feat/sandbox-network-policy`, `python3 -m venv /venv && + * /venv/bin/pip install tqdm` now succeeds, with no tool and no + * card. The proxy did not create the intent to bypass, it removed the accident + * that used to prevent it. + * + * Matching is over the tokenised argv `bash.ts` already builds from + * tree-sitter, not over the raw command line: `echo pip install numpy` is one + * command whose name is `echo`, and a regex over the line cannot tell the + * difference. + */ +export namespace Refuse { + /** Subcommands that mutate an environment. `list`, `show`, `--version` and + * friends are read-only questions and stay allowed — refusing them would + * break ordinary inspection and teach the agent the tool is unreliable. */ + const mutating = new Set(["install", "add", "uninstall", "remove"]) + + /** The last path segment, so `/work/venv/bin/pip` matches `pip`. */ + const leaf = (value: string) => value.split(/[/\\]/).pop() ?? value + + /** `python`, `python3`, `python3.14`, `/usr/bin/python3` — any of which can + * carry `-m pip`. */ + const python = (value: string) => /^python[0-9.]*$/.test(leaf(value)) + + const message = (packages: string[]) => + [ + "Refused: package installation goes through the `package_install` tool, not the shell.", + packages.length ? `Requested: ${packages.join(", ")}.` : "", + "Call `package_install` instead — it asks the user for approval, installs into a managed environment, and reports the versions it landed.", + "This applies to a virtualenv you create yourself in the workspace as well: the environment is the tool's to own.", + ] + .filter(Boolean) + .join(" ") + + /** Operands that look like package names, for the refusal message. Flags and + * their values are dropped; so is `-r requirements.txt`. */ + const operands = (rest: string[]) => { + const values: string[] = [] + for (let i = 0; i < rest.length; i++) { + const arg = rest[i]! + if (arg === "-r" || arg === "--requirement" || arg === "-c" || arg === "--constraint") { + i++ + continue + } + if (arg.startsWith("-")) continue + values.push(arg) + } + return values + } + + /** + * A refusal message when `command` is a package-installer invocation, + * `undefined` otherwise. `command` is the tokenised argv of one command node. + */ + export function installer(command: string[]): string | undefined { + const head = command[0] + if (!head) return undefined + const name = leaf(head) + + // `python -m pip install ...` / `./venv/bin/python -m pip install ...` + if (python(head) && command[1] === "-m" && command[2] === "pip") { + if (!mutating.has(command[3] ?? "")) return undefined + return message(operands(command.slice(4))) + } + + // `uv pip install ...` + if (name === "uv" && command[1] === "pip") { + if (!mutating.has(command[2] ?? "")) return undefined + return message(operands(command.slice(3))) + } + + // `pip install ...`, `pip3 install ...`, `/work/venv/bin/pip install ...` + if (/^pip[0-9.]*$/.test(name)) { + if (!mutating.has(command[1] ?? "")) return undefined + return message(operands(command.slice(2))) + } + + // `conda install ...`, `mamba install ...` + if (name === "conda" || name === "mamba") { + if (!mutating.has(command[1] ?? "")) return undefined + return message(operands(command.slice(2))) + } + + // `poetry add ...` + if (name === "poetry") { + if (!mutating.has(command[1] ?? "")) return undefined + return message(operands(command.slice(2))) + } + + return undefined + } +} diff --git a/backend/cli/src/package/requirement.ts b/backend/cli/src/package/requirement.ts new file mode 100644 index 00000000..bbd4fea2 --- /dev/null +++ b/backend/cli/src/package/requirement.ts @@ -0,0 +1,115 @@ +/** + * A deliberate PEP 508 subset: name, extras, version specifiers, environment + * markers, and the `name @ url` direct-reference form. Markers are captured but + * never evaluated — nothing here needs to. + * + * The spec's requirement is "parse with a real parser", meaning specifically: + * do not split on `==`. A split mishandles `numpy>=2.4`, `pandas[performance]` + * and `tqdm; python_version >= "3.9"`, and a mis-parsed name becomes a wrong + * permission pattern — an approval for something other than what runs. So + * anything outside this grammar throws rather than being guessed at. + */ +export namespace Requirement { + export type Parsed = { + name: string + extras: string[] + specifier: string + marker: string + url: string + } + + /** + * PEP 503 normalisation: runs of `-`, `_` and `.` collapse to one `-`, and + * comparison is lowercase. `Foo_Bar`, `Foo.Bar` and `foo-bar` are one + * package. Treating them as three would let an upgrade look additive to + * `Environment.additive`, which decides whether live kernels restart. + */ + const normalise = (value: string) => value.replace(/[-_.]+/g, "-").toLowerCase() + + const NAME = /^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$/ + + /** + * One comparison clause, anchored end to end: an operator followed by a + * version that actually starts with an alphanumeric. + * + * Anchoring matters more than it looks. A prefix test like + * `/^(===|==|…|>|<)\s*\S/` accepts `numpy >= ` — the alternation backtracks + * to the single-character `>` and then happily consumes the `=` as the + * version. A dangling operator would then reach pip as a literal + * requirement, having passed validation. + */ + const CLAUSE = /^(===|==|!=|~=|>=|<=|>|<)\s*[A-Za-z0-9][A-Za-z0-9.*+!_-]*$/ + + /** Every comma-separated clause must be well formed — `>=2.1,<3` is two. */ + const valid = (specifier: string) => + specifier + .split(",") + .map((clause) => clause.trim()) + .every((clause) => CLAUSE.test(clause)) + + export function parse(value: string): Parsed { + const text = value.trim() + if (!text) throw new Error(`Not a package requirement: ${JSON.stringify(value)}`) + + const [head, ...rest] = text.split(";") + const marker = rest.join(";").trim() + const body = head!.trim() + if (!body) throw new Error(`Not a package requirement: ${JSON.stringify(value)}`) + + const at = body.indexOf("@") + if (at !== -1) { + const name = body.slice(0, at).trim() + const url = body.slice(at + 1).trim() + if (!NAME.test(name) || !url) throw new Error(`Not a package requirement: ${JSON.stringify(value)}`) + return { name: normalise(name), extras: [], specifier: "", marker, url } + } + + const match = body.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)\s*(\[[^\]]*\])?\s*(.*)$/) + if (!match) throw new Error(`Not a package requirement: ${JSON.stringify(value)}`) + const [, raw, bracket, tail] = match + if (!raw || !NAME.test(raw)) throw new Error(`Not a package requirement: ${JSON.stringify(value)}`) + + const extras = bracket + ? bracket + .slice(1, -1) + .split(",") + .map((e) => e.trim()) + .filter(Boolean) + : [] + + const specifier = (tail ?? "").trim() + if (specifier && !valid(specifier)) throw new Error(`Not a package requirement: ${JSON.stringify(value)}`) + + return { name: normalise(raw), extras, specifier: specifier.replace(/\s+/g, ""), marker, url: "" } + } + + /** + * Strip credentials and scheme from an index URL. + * + * Credentials are environment config, not part of the approved action: + * rotating a token must not invalidate a standing grant, and a secret must + * never be rendered on a card the user is about to screenshot. + */ + export function redact(index: string) { + const trimmed = index.trim() + const withoutScheme = trimmed.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, "") + const at = withoutScheme.lastIndexOf("@") + return (at === -1 ? withoutScheme : withoutScheme.slice(at + 1)).replace(/\/+$/, "") + } + + /** + * The canonical command string — both what the approval card shows and what + * the permission system matches. Readable on purpose, unlike a digest: change + * the environment, the packages or the index and it is a different string, so + * the prompt reappears for free. + * + * Names only, sorted. Sorted so the same set in a different argument order + * matches an existing grant instead of prompting again; names only because + * resolution happens after approval — the card shows the request, so pinning + * a version must not fragment a grant the user already gave. + */ + export function pattern(input: { packages: string[]; environment: string; index: string }) { + const names = input.packages.map((p) => parse(p).name).toSorted() + return `install ${names.join(" ")} → ${input.environment} [${input.index}]` + } +} diff --git a/backend/cli/src/project/execution.ts b/backend/cli/src/project/execution.ts index 53c37e24..a9a9a969 100644 --- a/backend/cli/src/project/execution.ts +++ b/backend/cli/src/project/execution.ts @@ -32,6 +32,20 @@ export namespace ExecutionAuthority { ]) export type Capability = z.infer + // Not pure in-memory state: `compute/jobs.ts`'s `Job.authority` field + // stores a `Decision` verbatim in the on-disk job history (`jobs.json`), so + // `sandbox.network` below is a *second* copy of the persisted enum that + // `Job.sandbox.network` also carries — widening it (e.g. adding + // "allowlist") has the same one-directional compatibility cost: a job + // record this binary writes with the new value is rejected by an older + // binary reading the same history. The stakes are higher than "that one + // record" though — `ComputeJobs`'s `read()` runs `Job.array().safeParse()` + // over the *whole file* and throws `ComputeJobsCorruptError` for all of it + // on any single unparseable record (`compute/jobs.ts`'s `read()`), moving + // the file aside as `.corrupt-` rather than skipping the bad one. So + // one job with `sandbox.network: "allowlist"` written by a newer binary + // makes an older binary reject its entire compute job history, not just + // fail to display that job. export const Decision = z.object({ allowed: z.boolean(), reason: z.enum(["allowed", "project_untrusted", "sandbox_unavailable"]), @@ -46,10 +60,10 @@ export namespace ExecutionAuthority { writable: z.array(z.string()), sandbox: z.object({ enabled: z.boolean(), - network: z.enum(["allow", "deny"]), + network: z.enum(["deny", "allowlist", "allow"]), allowWrite: z.array(z.string()), onUnavailable: z.enum(["warn", "error", "allow"]), - backend: z.enum(["seatbelt", "bubblewrap", "none"]), + backend: z.enum(["seatbelt", "bubblewrap", "appcontainer", "none"]), available: z.boolean(), enforced: z.boolean(), }), diff --git a/backend/cli/src/pty/index.ts b/backend/cli/src/pty/index.ts index a9656744..54a1df59 100644 --- a/backend/cli/src/pty/index.ts +++ b/backend/cli/src/pty/index.ts @@ -10,6 +10,7 @@ import { lazy } from "@synsci/util/lazy" import { Shell } from "@/shell/shell" import { ExecutionAuthority } from "@/project/execution" import { Sandbox } from "@/sandbox/sandbox" +import { EgressRuntime } from "@/sandbox/egress-runtime" import { OpenScience } from "@/openscience" import { terminalArgs, terminalEnv } from "./environment" @@ -108,14 +109,21 @@ export namespace Pty { const args = terminalArgs(command) const cwd = authority.workspace const source = await OpenScience.subprocessEnv(process.env) - const env = terminalEnv(source, Instance.project.id, input.sessionID, command) + const egress = await EgressRuntime.egressFor(authority.sandbox) const sandbox = Sandbox.wrapArgv({ file: command, args, workspace: authority.writable, unreadable: OpenScience.kernelSensitivePaths(), - options: authority.sandbox, + options: { ...authority.sandbox, egress }, }) + // terminalEnv gained a `command` argument on main (it picks the PS1/PROMPT + // shape from the shell); the env still has to be built *after* wrapArgv, + // because sandbox.env carries the proxy variables the shim needs. + const env = { + ...terminalEnv(source, Instance.project.id, input.sessionID, command), + ...(sandbox.env ?? {}), + } log.info("creating session", { id, cmd: command, args, cwd }) const spawn = await pty() diff --git a/backend/cli/src/sandbox/appcontainer.ts b/backend/cli/src/sandbox/appcontainer.ts new file mode 100644 index 00000000..fcf27909 --- /dev/null +++ b/backend/cli/src/sandbox/appcontainer.ts @@ -0,0 +1,369 @@ +/** + * Windows AppContainer launcher. + * + * Linux and macOS both have a wrapper executable — `bwrap`, `sandbox-exec` — + * so confinement is expressible as an argv. Windows has none: it is applied AT + * process creation, by passing `SECURITY_CAPABILITIES` through + * `UpdateProcThreadAttribute` to `CreateProcessW`. So the binary launches + * itself (`openscience __appcontainer-launch -- `, the pattern + * `__egress-shim` already established) and this module does the Win32 work. + * + * Every call is modelled on `docs/specs/windows-appcontainer-probe.ps1`, which + * ran this exact sequence on a real Windows 11 machine, unelevated, and + * measured it working: profile creation, a child launched with zero + * capabilities, that child unable to reach the network, and two children in the + * same container able to talk over loopback. + * + * The x64 struct offsets below are written out beside the fields they belong + * to rather than derived. Getting one wrong produces a `CreateProcess` failure + * that reads as a permissions problem rather than a marshalling one, and that + * is an expensive hour on a machine none of us can debug interactively. + * + * The FFI patterns used here — an out-parameter pointer read back with + * `read.ptr`, and bytes read at a returned pointer with `toArrayBuffer` — were + * verified against libc on Linux before being written, because the mechanism + * is the same and the platform is not available to test on. + */ + +export namespace AppContainer { + /** What the launcher is told to do, carried as one base64 blob through the + * command line. Kept in step with `Sandbox.appContainerArgs`. */ + export type Spec = { + profile: string + writable: string[] + unreadable: string[] + network: "deny" | "allowlist" | "allow" + /** Broker pipe name, when network is "allowlist". */ + pipe?: string + } + + export function decode(blob: string): Spec { + const value = JSON.parse(Buffer.from(blob, "base64").toString("utf8")) as Spec + if (!value?.profile) throw new Error("appcontainer spec carries no profile name") + if (!Array.isArray(value.writable)) throw new Error("appcontainer spec carries no writable list") + return value + } + + /** A null-terminated UTF-16LE buffer, which every `...W` entry point expects. + * Bun's FFI has no wide-string type, so strings cross as pointers to buffers + * the caller keeps alive for the duration of the call. */ + export function wide(value: string) { + return Buffer.from(`${value}\0`, "utf16le") + } + + /** Reads a null-terminated UTF-16LE string out of a byte view. */ + export function readWide(bytes: Uint8Array) { + const chars: number[] = [] + for (let i = 0; i + 1 < bytes.length; i += 2) { + const code = bytes[i]! | (bytes[i + 1]! << 8) + if (code === 0) break + chars.push(code) + } + return String.fromCharCode(...chars) + } + + // ── x64 layouts ─────────────────────────────────────────────────────────── + /** SECURITY_CAPABILITIES { PSID AppContainerSid; PSID_AND_ATTRIBUTES* ; DWORD CapabilityCount; DWORD Reserved } */ + const SECURITY_CAPABILITIES_SIZE = 24 + /** STARTUPINFOW is 104 bytes on x64; STARTUPINFOEXW appends lpAttributeList at 104. */ + const STARTUPINFOEX_SIZE = 112 + const STARTUPINFO_CB_OFFSET = 0 + const STARTUPINFO_ATTRIBUTE_LIST_OFFSET = 104 + /** PROCESS_INFORMATION { HANDLE hProcess; HANDLE hThread; DWORD pid; DWORD tid } */ + const PROCESS_INFORMATION_SIZE = 24 + const PI_PROCESS_OFFSET = 0 + + const PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES = 0x00020009 + const EXTENDED_STARTUPINFO_PRESENT = 0x00080000 + const CREATE_UNICODE_ENVIRONMENT = 0x00000400 + /** HRESULT_FROM_WIN32(ERROR_ALREADY_EXISTS). The profile is per-user state + * that outlives a run by design, so this is the ordinary path. */ + const ALREADY_EXISTS = 0x800700b7 + const INFINITE = 0xffffffff + + type Bound = ReturnType + + function bind() { + if (process.platform !== "win32") throw new Error("the AppContainer launcher only runs on Windows") + // Required lazily and by name so `bun:ffi` never enters the module graph on + // platforms that cannot call this. These DLLs ship with Windows, so nothing + // additional is distributed. + const ffi = require("bun:ffi") as typeof import("bun:ffi") + const t = ffi.FFIType + return { + ffi, + userenv: ffi.dlopen("userenv.dll", { + CreateAppContainerProfile: { args: [t.ptr, t.ptr, t.ptr, t.ptr, t.u32, t.ptr], returns: t.i32 }, + DeriveAppContainerSidFromAppContainerName: { args: [t.ptr, t.ptr], returns: t.i32 }, + }).symbols, + advapi: ffi.dlopen("advapi32.dll", { + ConvertSidToStringSidW: { args: [t.ptr, t.ptr], returns: t.bool }, + FreeSid: { args: [t.ptr], returns: t.ptr }, + }).symbols, + kernel: ffi.dlopen("kernel32.dll", { + LocalFree: { args: [t.ptr], returns: t.ptr }, + GetLastError: { args: [], returns: t.u32 }, + InitializeProcThreadAttributeList: { args: [t.ptr, t.u32, t.u32, t.ptr], returns: t.bool }, + UpdateProcThreadAttribute: { args: [t.ptr, t.u32, t.u64, t.ptr, t.u64, t.ptr, t.ptr], returns: t.bool }, + DeleteProcThreadAttributeList: { args: [t.ptr], returns: t.void }, + CreateProcessW: { + args: [t.ptr, t.ptr, t.ptr, t.ptr, t.bool, t.u32, t.ptr, t.ptr, t.ptr, t.ptr], + returns: t.bool, + }, + WaitForSingleObject: { args: [t.ptr, t.u32], returns: t.u32 }, + GetExitCodeProcess: { args: [t.ptr, t.ptr], returns: t.bool }, + CloseHandle: { args: [t.ptr], returns: t.bool }, + }).symbols, + } + } + + /** + * Can this machine actually be confined by us? + * + * Loads the DLLs and derives a SID from a name. That is side-effect free — no + * profile is created — and it exercises the part most likely to be wrong: + * whether the FFI bindings resolve and the calling convention is right. A + * broken binding here is the difference between the sandbox being applied and + * silently not being. + * + * It does NOT prove the launch itself works. That is verified at first use, + * where `launch` throws with the Win32 error rather than degrading quietly. + * The alternative — assuming Windows can be confined because the platform + * says win32 — is how you ship a product that claims a sandbox it never + * applies. + */ + export function usable(): boolean { + if (process.platform !== "win32") return false + try { + const b = bind() + const out = new BigUint64Array(1) + const hr = b.userenv.DeriveAppContainerSidFromAppContainerName( + b.ffi.ptr(wide("openscience-capability")), + b.ffi.ptr(out), + ) + if (hr !== 0) return false + const sid = b.ffi.read.ptr(b.ffi.ptr(out), 0) + if (sid) b.advapi.FreeSid(sid as never) + return true + } catch { + return false + } + } + + /** + * Create the profile if absent, and return its package SID as a string. + * + * Idempotent by design. The profile is per-user state that outlives a run, + * and the SID derived from it is what filesystem ACEs and the broker pipe's + * DACL refer to — recreating it per launch would strand every grant the + * previous one made. That is why `Sandbox.appContainerProfile` derives a + * stable name from the workspace rather than generating one. + */ + export function ensureProfile(name: string, b: Bound = bind()): string { + const { ffi, userenv, advapi, kernel } = b + const wname = wide(name) + const display = wide(name) + const description = wide("OpenScience sandbox") + const sidOut = new BigUint64Array(1) + + let hr = userenv.CreateAppContainerProfile( + ffi.ptr(wname), + ffi.ptr(display), + ffi.ptr(description), + null, + 0, + ffi.ptr(sidOut), + ) + if (hr >>> 0 === ALREADY_EXISTS) { + hr = userenv.DeriveAppContainerSidFromAppContainerName(ffi.ptr(wname), ffi.ptr(sidOut)) + if (hr !== 0) throw new Error(`DeriveAppContainerSidFromAppContainerName failed: 0x${(hr >>> 0).toString(16)}`) + } else if (hr !== 0) { + throw new Error( + `CreateAppContainerProfile failed: 0x${(hr >>> 0).toString(16)}. Windows sandboxing rests on this call; ` + + `without it nothing can be confined. It is expected to succeed for a standard user, unelevated.`, + ) + } + + const sid = ffi.read.ptr(ffi.ptr(sidOut), 0) + const strOut = new BigUint64Array(1) + if (!advapi.ConvertSidToStringSidW(sid as never, ffi.ptr(strOut))) { + throw new Error(`ConvertSidToStringSid failed: Win32 ${kernel.GetLastError()}`) + } + const strPtr = ffi.read.ptr(ffi.ptr(strOut), 0) + // A package SID is well under 512 UTF-16 code units; readWide stops at the + // first null either way. + const text = readWide(new Uint8Array(ffi.toArrayBuffer(strPtr as never, 0, 1024))) + kernel.LocalFree(strPtr as never) + advapi.FreeSid(sid as never) + return text + } + + /** + * Grant the package SID access to paths the sandboxed process must write. + * + * An AppContainer reaches nothing outside its own package folders, so the + * workspace has to be granted explicitly. `icacls` rather than + * `SetNamedSecurityInfo` through FFI: it ships with Windows, takes a SID + * directly in the `*S-1-...` form, and a shelled command that fails is far + * easier to diagnose than a marshalled ACL that silently grants the wrong + * thing. The probe measured that the package's OWN temp is already writable + * with no grant, so only caller-supplied paths are touched. + * + * Returns the paths it could not grant rather than throwing: a workspace that + * is partly ungrantable should still launch and fail visibly at the write, + * not vanish behind a launcher error. + */ + export function grant(sid: string, paths: string[]) { + const failures: string[] = [] + for (const target of paths) { + const proc = Bun.spawnSync(["icacls.exe", target, "/grant", `*${sid}:(OI)(CI)(F)`, "/Q"], { + stdout: "ignore", + stderr: "pipe", + }) + if (proc.exitCode !== 0) failures.push(`${target}: ${proc.stderr.toString().trim() || `exit ${proc.exitCode}`}`) + } + return failures + } + + /** + * Quote one argument the way `CommandLineToArgvW` will parse it back. + * + * Windows has no argv: `CreateProcess` takes a single string and the child + * re-splits it. The rules are neither the shell's nor POSIX's — backslashes + * are literal except immediately before a quote, where they double. Getting + * this wrong on a path like `C:\Users\me\My Project\` silently changes what + * the child runs, which is the whole reason the sandbox spec travels as + * base64 rather than as flags. + */ + export function quote(value: string) { + if (value.length && !/[\s"]/.test(value)) return value + let out = '"' + let slashes = 0 + for (const ch of value) { + if (ch === "\\") { + slashes++ + continue + } + if (ch === '"') { + out += "\\".repeat(slashes * 2 + 1) + '"' + slashes = 0 + continue + } + out += "\\".repeat(slashes) + ch + slashes = 0 + } + return `${out}${"\\".repeat(slashes * 2)}"` + } + + export function commandLine(argv: string[]) { + return argv.map(quote).join(" ") + } + + /** + * Launch `argv` inside the AppContainer for `sid`, with NO capabilities, and + * return its exit code. + * + * Zero capabilities is the entire point: no `internetClient`, nothing. The + * probe measured that such a container reaches no external host, no host + * loopback listener, and resolves no DNS, while remaining able to talk to + * another process in the same container over loopback — which is what makes + * the shim model viable here. + */ + export function launch(sid: string, argv: string[], b: Bound = bind()): number { + const { ffi, advapi, kernel } = b + const sidBuf = new BigUint64Array(1) + // ConvertStringSidToSidW is bound here rather than in `bind()` so the + // launcher's read-only surface stays small; the SID text came from us. + const convert = ffi.dlopen("advapi32.dll", { + ConvertStringSidToSidW: { args: [ffi.FFIType.ptr, ffi.FFIType.ptr], returns: ffi.FFIType.bool }, + }).symbols + if (!convert.ConvertStringSidToSidW(ffi.ptr(wide(sid)), ffi.ptr(sidBuf))) { + throw new Error(`ConvertStringSidToSid failed for ${sid}: Win32 ${kernel.GetLastError()}`) + } + const sidPtr = ffi.read.ptr(ffi.ptr(sidBuf), 0) + + // Size the attribute list, then allocate and initialise it. The first call + // is expected to fail with ERROR_INSUFFICIENT_BUFFER; only the size matters. + const sizeOut = new BigUint64Array(1) + kernel.InitializeProcThreadAttributeList(null, 1, 0, ffi.ptr(sizeOut)) + const listSize = Number(sizeOut[0]!) + if (!listSize) throw new Error("InitializeProcThreadAttributeList reported a zero-length attribute list") + const attributes = new Uint8Array(listSize) + if (!kernel.InitializeProcThreadAttributeList(ffi.ptr(attributes), 1, 0, ffi.ptr(sizeOut))) { + throw new Error(`InitializeProcThreadAttributeList failed: Win32 ${kernel.GetLastError()}`) + } + + const capabilities = new Uint8Array(SECURITY_CAPABILITIES_SIZE) + new DataView(capabilities.buffer).setBigUint64(0, BigInt(sidPtr as number), true) + // Capabilities pointer stays null and CapabilityCount stays 0 — that is the + // containment. + + if ( + !kernel.UpdateProcThreadAttribute( + ffi.ptr(attributes), + 0, + BigInt(PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES) as never, + ffi.ptr(capabilities), + BigInt(SECURITY_CAPABILITIES_SIZE) as never, + null, + null, + ) + ) { + throw new Error(`UpdateProcThreadAttribute failed: Win32 ${kernel.GetLastError()}`) + } + + const startup = new Uint8Array(STARTUPINFOEX_SIZE) + const startupView = new DataView(startup.buffer) + startupView.setUint32(STARTUPINFO_CB_OFFSET, STARTUPINFOEX_SIZE, true) + startupView.setBigUint64(STARTUPINFO_ATTRIBUTE_LIST_OFFSET, BigInt(ffi.ptr(attributes)), true) + + const info = new Uint8Array(PROCESS_INFORMATION_SIZE) + // Mutable: CreateProcessW may write into lpCommandLine. + const line = wide(commandLine(argv)) + + const ok = kernel.CreateProcessW( + null, + ffi.ptr(line), + null, + null, + false, + EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, + null, + null, + ffi.ptr(startup), + ffi.ptr(info), + ) + kernel.DeleteProcThreadAttributeList(ffi.ptr(attributes)) + if (!ok) { + throw new Error( + `CreateProcess into the AppContainer failed: Win32 ${kernel.GetLastError()}. ` + + `Win32 5 is access denied; 2 means the executable was not found.`, + ) + } + + const handle = ffi.read.ptr(ffi.ptr(info), PI_PROCESS_OFFSET) + kernel.WaitForSingleObject(handle as never, INFINITE) + const codeOut = new Uint32Array(1) + kernel.GetExitCodeProcess(handle as never, ffi.ptr(codeOut)) + kernel.CloseHandle(handle as never) + return codeOut[0]! + } + + /** + * The `__appcontainer-launch` entry point: decode the spec, ensure the + * profile, grant the workspace, run the real command, propagate its exit + * code. + * + * Grant failures are reported on stderr rather than thrown. The command + * should still run and fail visibly at the write it cannot make, rather than + * disappearing behind a launcher error that says nothing about what the user + * actually asked for. + */ + export async function main(blob: string, argv: string[]): Promise { + const spec = decode(blob) + const sid = ensureProfile(spec.profile) + const failures = grant(sid, spec.writable) + for (const failure of failures) process.stderr.write(`openscience: could not grant sandbox access to ${failure}\n`) + return launch(sid, argv) + } +} diff --git a/backend/cli/src/sandbox/egress-runtime.ts b/backend/cli/src/sandbox/egress-runtime.ts new file mode 100644 index 00000000..e3dbcb33 --- /dev/null +++ b/backend/cli/src/sandbox/egress-runtime.ts @@ -0,0 +1,289 @@ +import crypto from "crypto" +import fs from "fs/promises" +import path from "path" +import { Config } from "@/config/config" +import { Global } from "@/global" +import { GlobalBus } from "@/bus/global" +import { Event } from "@/server/event" +import { Log } from "@/util/log" +import { Egress } from "./egress" +import { Sandbox } from "./sandbox" + +const log = Log.create({ service: "egress-runtime" }) + +/** + * Lifecycle for the host-side allowlist proxy — the listening end of + * `Egress.serveProxy` (see `egress.ts` for the proxy itself and + * `docs/adr/0002-sandbox-network-policy.md` for why it exists). + * + * One proxy per process, held lazily with a disposer, the same shape + * `science/kernel/registry.ts` uses for its kernel table: nothing runs until + * the first `ensure()`, and `stop()` tears down the server and unlinks the + * socket. Unlike that table, this is not `Instance.state` — the proxy must + * outlive any single project instance, since a global config write disposes + * every open instance (`Config`'s `patchConfigPath`) and the proxy must not + * go down with them, or every kernel bound to its socket would lose its only + * route out. + * + * `rules` is a live array, not a snapshot. `Egress.serveProxy` reads it by + * reference on every connection, so refreshing its *contents* in place — + * on every `ensure()`, and reactively whenever global config changes — is + * what lets an allowlist edit reach a kernel that is already running, + * without restarting the proxy or the kernel. Building a fresh array once + * at construction and handing it to `serveProxy` would silently defeat + * that: the proxy would keep the rules it was born with until the process + * itself restarted. This is also why `allowHosts` stays out of + * `ExecutionAuthority.generation` — that hash exists to decide when a + * kernel must be torn down and rebooted, and an allowlist edit is + * deliberately not that kind of change. + */ +export namespace EgressRuntime { + /** + * Bubblewrap (Linux) carries `socket` — the bind-mounted socket itself is + * the sandboxed process's only route in. Seatbelt (macOS) has no network + * namespace to bind a socket into, so `Egress.serveProxy` listens directly + * on a loopback TCP port instead (see its own doc comment and + * `sandbox.ts`'s `seatbeltProfile`), carried as `hostname`/`secret` — + * `secret` is the per-start `Proxy-Authorization` credential that port + * requires, since a loopback TCP port, unlike a unix socket, carries no + * filesystem permissions of its own. Optional fields rather than a + * discriminated union: every real caller narrows by checking `socket` + * (see `stop()`, `ensure()`, `egressFor()` below), and a union would force + * that same narrowing onto every *test* that reaches these fields too, + * including the bubblewrap-only ones this task must leave unchanged. + */ + type Running = { + socket?: string + hostname?: string + port: number + secret?: string + // Not `ReturnType`: TS resolves that utility + // against an overloaded function's LAST signature only (the TCP one + // here), not a union of all of them — this field needs both, since + // `startBubblewrap`'s `server` really is a `UnixSocketListener`. + server: Bun.UnixSocketListener | Bun.TCPSocketListener + rules: Egress.Rule[] + onGlobalChange: (event: { directory?: string; payload: unknown }) => void + } + + const state: { running?: Promise } = {} + + async function currentRules(): Promise { + const policy = await Config.trustedSandbox() + return [...Egress.DEFAULT_RULES, ...(policy.allowHosts ?? [])] + } + + /** Re-populate `rules` in place (same array reference) rather than + * replacing it, so `Egress.serveProxy`'s closure over that reference + * observes the update on its very next connection. A failed re-read + * (config file briefly unreadable mid-write, for example) keeps + * whatever rules were already live rather than clearing the allowlist. */ + async function refresh(rules: Egress.Rule[]) { + const next = await currentRules().catch((error) => { + log.warn("failed to refresh the sandbox allowlist, keeping the previous rules", { error }) + return undefined + }) + if (!next) return + rules.length = 0 + rules.push(...next) + } + + function isGlobalConfigChange(event: { directory?: string; payload: unknown }): boolean { + if (event.directory !== "global") return false + const payload = event.payload + if (typeof payload !== "object" || payload === null || !("type" in payload)) return false + return payload.type === Event.Disposed.type + } + + function listener(rules: Egress.Rule[]) { + const onGlobalChange = (event: { directory?: string; payload: unknown }) => { + if (!isGlobalConfigChange(event)) return + refresh(rules).catch(() => {}) + } + GlobalBus.on("event", onGlobalChange) + return onGlobalChange + } + + /** Bubblewrap (Linux): a bind-mountable unix socket under the state dir — + * unchanged from before Task 7 (macOS seatbelt support) added the + * loopback-TCP branch below. */ + async function startBubblewrap(): Promise { + const socket = path.join(Global.Path.state, `egress-${process.pid}.sock`) + // A stale socket file from a killed previous process (same pid, unlikely + // but possible after a pid wraparound) would make Bun.listen refuse to + // bind with EADDRINUSE. + await fs.rm(socket, { force: true }) + const rules = await currentRules() + // Bun.listen throws synchronously, with a message ("Failed to listen at + // ") that says nothing about what depends on it. Every sandboxed + // spawn does, so name that here rather than letting a bare bind error + // surface out of an unrelated-looking `bash`/kernel/job call. + const server = (() => { + try { + return Egress.serveProxy({ socket, rules, onEvent: (line) => log.info(line) }) + } catch (e) { + throw new Error( + `Could not start the sandbox allowlist proxy on ${socket}: ${e instanceof Error ? e.message : String(e)}. ` + + `Sandboxed commands need it to reach the network — retry once the path is writable, or set sandbox.network to "deny" or "allow".`, + ) + } + })() + const onGlobalChange = listener(rules) + log.info("egress proxy listening", { socket }) + return { socket, port: Sandbox.SHIM_PORT, server, rules, onGlobalChange } + } + + /** + * Seatbelt (macOS): no network namespace to bind a unix socket into, so + * `Egress.serveProxy` listens directly on a loopback TCP port instead — + * decision 1 of the Task 7 brief, deliberately *not* a host-side bridge + * from TCP to a unix socket (that would just be a second component doing + * what one listener already can). `port: 0` asks the OS for an ephemeral + * port: unlike bubblewrap's `SHIM_PORT`, nothing needs this value fixed in + * advance — no shim script embeds it as a literal, since seatbelt has no + * shim at all (see `sandbox.ts`'s `shimPlan` doc comment) — and a fixed + * port here, with no namespace to keep it private, would collide across + * every concurrently sandboxed process on the machine. + * + * `secret` is generated fresh per proxy start (decision 2): a loopback TCP + * port, unlike a unix socket, carries no filesystem permissions of its + * own, so every request to it must additionally prove it holds this — + * enforced inside `Egress.serveProxy` itself, not here. + */ + async function startSeatbelt(): Promise { + const rules = await currentRules() + const secret = crypto.randomUUID() + const server = (() => { + try { + return Egress.serveProxy({ hostname: "127.0.0.1", port: 0, secret, rules, onEvent: (line) => log.info(line) }) + } catch (e) { + throw new Error( + `Could not start the sandbox allowlist proxy on 127.0.0.1: ${e instanceof Error ? e.message : String(e)}. ` + + `Sandboxed commands need it to reach the network — retry, or set sandbox.network to "deny" or "allow".`, + ) + } + })() + const onGlobalChange = listener(rules) + log.info("egress proxy listening", { hostname: "127.0.0.1", port: server.port }) + return { hostname: "127.0.0.1", port: server.port, secret, server, rules, onGlobalChange } + } + + /** + * `platform` decides which of the two listeners above starts — defaulting + * to the real platform, like every other platform-injectable seam this + * branch added (`Sandbox.backend`, `plan`/`wrapArgv`), so the seatbelt + * branch is exercisable, deterministically, from a machine that has none. + */ + function start(platform: NodeJS.Platform = process.platform): Promise { + return platform === "darwin" ? startSeatbelt() : startBubblewrap() + } + + /** Start the proxy if it is not already running, and return where to + * reach it. Idempotent — a second call returns the same address without + * restarting anything. Also refreshes the live rules from the current + * config, so a caller composing a new sandboxed argv always gets the + * latest allowlist even between reactive updates. + * + * A failure is loud but never permanent. Caching the promise is what makes + * the success path idempotent, and it would just as happily cache a + * rejection: one transient failure — a state directory briefly unwritable, + * a socket path momentarily taken — would then be replayed to every later + * caller for the life of the process, and since every bash command, + * terminal, kernel and compute job routes through here under the + * "allowlist" default, that is the whole product failing until restart. + * So a rejected start un-caches itself and the next call genuinely + * retries. It still throws rather than degrading to no-proxy: `wrapArgv` + * would reject an "allowlist" policy with no egress socket anyway, and a + * silent downgrade is exactly the failure this feature keeps producing — + * a sandbox that looks like it has bounded egress and in fact has none. + * + * `platform` decides only which listener `start()` picks — see its doc + * comment — and is only ever non-default from a test; every real caller + * (`egressFor` below) leaves it at the real one. The proxy itself is not + * re-created per platform: `state.running` is one proxy for the process + * lifetime, same as before this parameter existed, so a caller that wants + * a differently-platformed proxy exercised must `stop()` first. */ + export async function ensure( + platform: NodeJS.Platform = process.platform, + ): Promise<{ socket?: string; hostname?: string; port: number; secret?: string }> { + const pending = (state.running ??= start(platform)) + const running = await pending.catch((error) => { + if (state.running === pending) state.running = undefined + throw error + }) + await refresh(running.rules) + return { socket: running.socket, hostname: running.hostname, port: running.port, secret: running.secret } + } + + /** Stop the proxy. The CLI process otherwise leaves this running for its + * own lifetime; tests use this to reset between cases. A no-op when + * nothing is running, and — because a caller reaching for the escape + * hatch after a failed start must not be handed that same failure again — + * when the last start rejected. Unlinks the unix socket on bubblewrap; + * seatbelt's loopback listener leaves nothing on disk to clean up. */ + export async function stop() { + const pending = state.running + state.running = undefined + if (!pending) return + const running = await pending.catch(() => undefined) + if (!running) return + GlobalBus.off("event", running.onGlobalChange) + running.server.stop(true) + if (running.socket) await fs.rm(running.socket, { force: true }) + } + + /** The value to pass as `Sandbox.Options.egress`, or `undefined` when the + * proxy would not actually be used: the sandbox is off, network isn't + * "allowlist", or the platform's backend is neither bubblewrap nor + * seatbelt. The shape differs by backend, matching `Options.egress`'s own + * doc comment: bubblewrap gets the bind-mountable unix socket path, since + * the bind-mounted socket itself is the sandboxed process's only route in; + * seatbelt gets `":"` — `buildPolicy` in sandbox.ts is what + * splits that back apart into `Policy.port`/`Policy.secret`, the same + * division of labour it already has for bubblewrap's `Policy.egress`. A + * disabled/deny/allow policy skips starting the proxy entirely — pure + * waste when nothing would ever connect to it. Every `wrapArgv` / + * `plan()` caller should route through this rather than calling `ensure()` + * directly, so a terminal or kernel with network "deny" never pays for a + * proxy it has no way to reach. + * + * `platform` defaults to the real one — the same injectable seam + * `Sandbox.backend`/`plan`/`wrapArgv` use — so the seatbelt branch is + * exercisable, deterministically, from a machine that has none. + * + * `ensure()` caches ONE proxy for the process lifetime (see its doc + * comment); `platform` only decides which listener `start()` picks when + * nothing is running yet. Asking for `"darwin"` after a differently- + * platformed proxy is already cached (a real caller never does this — + * `process.platform` is constant for the life of a process — but a test + * injecting platform explicitly can) silently reuses that cached + * listener instead of starting a seatbelt one. Interpolating a + * bubblewrap `Running`'s missing `secret` into the template literal + * below would then produce the *string* `"undefined"` — truthy, and + * therefore indistinguishable from a real secret to any check that only + * asks whether the value is present. Guarded explicitly rather than + * trusting the interpolation to fail loudly on its own, because it + * doesn't: confirmed by execution (Task 7 fix round 1 review) that it + * silently composes `":undefined"` instead. */ + export async function egressFor( + policy: Sandbox.Options, + platform: NodeJS.Platform = process.platform, + ): Promise { + const { enabled, network } = Sandbox.resolved(policy) + if (!enabled) return undefined + if (network !== "allowlist") return undefined + const b = Sandbox.backend(platform) + if (b === "bubblewrap") return (await ensure(platform)).socket + if (b === "seatbelt") { + const running = await ensure(platform) + if (!running.secret) { + throw new Error( + "sandbox egress proxy is already running as the bubblewrap (unix-socket) listener, not seatbelt's — " + + "call EgressRuntime.stop() first if a seatbelt proxy is genuinely needed here", + ) + } + return `${running.port}:${running.secret}` + } + return undefined + } +} diff --git a/backend/cli/src/sandbox/egress-shim-entry.ts b/backend/cli/src/sandbox/egress-shim-entry.ts new file mode 100644 index 00000000..1fc33795 --- /dev/null +++ b/backend/cli/src/sandbox/egress-shim-entry.ts @@ -0,0 +1,56 @@ +/** + * Minimal, dependency-light entry point for the sandboxed loopback shim. In + * development `Sandbox.shimPlan()` bundles this file and execs `bun` against + * the bundle — never against `src/index.ts` — because `src/index.ts`'s graph + * pulls in `Global` (an unguarded top-level `await Bun.file(...).write(...)` + * at `src/global/index.ts` — `EROFS` under a read-only source tree) and + * `ModelsDev` (a live fetch at module-eval time). Both run before any argv + * check could skip them and would kill the shim under exactly the + * read-only/no-network conditions this mechanism exists to survive. This + * file imports only `./egress` (nothing but a Bun type) and the marker + * constant below (a single string, no other exports), so evaluating it does + * no I/O beyond the two lines that matter. + * + * `shimPlan()` does not exec this file: it runs `bun build` over it and execs + * the resulting self-contained bundle, because only the bundle's own path has + * to be visible inside the sandbox, where `--tmpfs /tmp` masks whatever it + * covers. So an added import does not have to live anywhere in particular — + * a sibling module and an npm package are equally fine, and the npm case is + * specifically what a package-root bind got wrong before (in this bun + * workspace `node_modules/` is a symlink into the monorepo-root store, + * above the package root: the link was bound, its target was not). + * + * Four things are still not safe to add here, and none of them are about + * where files live. Import-time side effects (a top-level fetch, a top-level + * write) run in the bundle exactly as they would in the source, and would + * reintroduce the failure this file exists to avoid — the shim dies under the + * read-only, no-network conditions it is supposed to survive, with its output + * on /dev/null. Resolving anything from `import.meta.dir`/`url` points at + * `Global.Path.bin`, where the bundle runs, not at this directory. A runtime + * `import(expression)` cannot be inlined by the bundler, so it would resolve + * against a path nothing bound (a literal `import("./x")` is inlined and + * fine). And a dependency that loads a native binding bundles cleanly but + * still `dlopen`s a `.so` at run time, from a path nothing bound and nothing + * checks — see `shimPlan`'s residual list for the measurement. + * + * A compiled release has no separate entry to redirect to — `bun --compile` + * embeds a single one — so it still goes through `index.ts`'s + * `__egress-shim` argv check and therefore still evaluates that full graph. + * See `sandbox.ts`'s `shimPlan` doc comment and the Task 4 report for what + * that leaves reachable in a compiled binary. + * + * `Sandbox.shimScript` composes one call shape for both modes — + * ` __egress-shim ` — because the compiled path needs + * the "__egress-shim" token to dispatch inside `index.ts`'s single-entry + * argv check. This file has no dispatching to do, so it ignores that token + * and reads port/socket positionally from the end instead of assuming a + * fixed prefix, which also means it still works if `shimScript` ever calls + * it without the token. + */ +import { Egress } from "./egress" +import { SHIM_READY_MARKER } from "./egress-shim-marker" + +const [port, socket] = process.argv.slice(-2) +Egress.serveShim({ port: Number(port), socket: socket! }) +await Bun.write(SHIM_READY_MARKER, "").catch(() => {}) +await new Promise(() => {}) diff --git a/backend/cli/src/sandbox/egress-shim-marker.ts b/backend/cli/src/sandbox/egress-shim-marker.ts new file mode 100644 index 00000000..275023cf --- /dev/null +++ b/backend/cli/src/sandbox/egress-shim-marker.ts @@ -0,0 +1,17 @@ +/** + * Readiness marker `Sandbox.shimScript`'s composed wait loop polls for, and + * the `__egress-shim` handler (`index.ts`, `egress-shim-entry.ts`) touches + * once `Egress.serveShim`'s listener is bound. + * + * A single exported constant, not three independently hardcoded copies of + * the same string: the three call sites drifting apart is a silent 3s stall + * on every sandboxed command, not a loud failure, so nothing would catch it + * happening. This file has no other exports and does nothing at import + * time, so importing it (including from `egress-shim-entry.ts`, which must + * stay dependency-light) costs nothing. + * + * Lives under `/tmp` deliberately: `bubblewrapArgs` always mounts `/tmp` as + * a fresh, process-private tmpfs, so a fixed name here can't collide across + * sandboxed processes or persist from a previous run. + */ +export const SHIM_READY_MARKER = "/tmp/.openscience-egress-shim.ready" diff --git a/backend/cli/src/sandbox/egress.ts b/backend/cli/src/sandbox/egress.ts new file mode 100644 index 00000000..59b91d7d --- /dev/null +++ b/backend/cli/src/sandbox/egress.ts @@ -0,0 +1,572 @@ +import type { Socket, SocketHandler } from "bun" + +/** + * Allowlist egress proxy for sandboxed kernels. + * + * `sandbox.network` is otherwise binary: deny (`--unshare-net`) locks out + * NCBI, UniProt, PDB and PyPI, which is most of the product's purpose; allow + * is unrestricted egress. This gives a middle: an allowlist proxy, with no + * direct DNS inside the sandbox — name resolution happens at the proxy. + * + * A bind-mounted unix socket still crosses `--unshare-net`'s network + * namespace, so it is the only route out, and the proxy on the other end + * decides what is reachable. No pasta, no nftables, no root. + * + * Two roles: + * serveProxy — runs on the HOST, speaks HTTP proxy. Listens on a unix + * socket for bubblewrap (Linux), or directly on a loopback TCP + * port for seatbelt (macOS), which has no network namespace to + * bind a socket into — see sandbox.ts's seatbeltProfile. The + * TCP form additionally requires a Proxy-Authorization secret, + * since a loopback port (unlike a unix socket) carries no + * filesystem permissions of its own. + * serveShim — runs INSIDE the sandbox, TCP on loopback → the unix socket, + * because pip/requests/curl take a host:port proxy, not a + * unix path. Bubblewrap-only: seatbelt's serveProxy needs no + * bridge, since it already listens on TCP directly. + * + * Ported from the feasibility spike on `proto/sandbox-allowlist-proxy` + * (`src/sandbox/prototype/proxy.ts`); see that branch's README for the + * measurements behind the design. + */ +export namespace Egress { + export type Rule = string + + /** Exact host, or a leading dot for suffix match: ".ncbi.nlm.nih.gov". */ + export function allowed(host: string, rules: Rule[]): boolean { + const name = host.toLowerCase().split(":")[0] + return rules.some((rule) => { + const value = rule.toLowerCase() + if (value.startsWith(".")) return name === value.slice(1) || name.endsWith(value) + return name === value + }) + } + + export const DEFAULT_RULES: Rule[] = [ + // package registries + "pypi.org", + ".pypi.org", + "files.pythonhosted.org", + ".pythonhosted.org", + "cran.r-project.org", + ".bioconductor.org", + // scientific APIs + ".ncbi.nlm.nih.gov", + ".uniprot.org", + ".rcsb.org", + ".ebi.ac.uk", + ".ensembl.org", + "arxiv.org", + ".arxiv.org", + ] + + /** + * One direction of a bridged pair, with backpressure. + * + * `Socket.write` returns how many bytes the socket actually accepted, and + * that is fewer than the whole chunk the moment the kernel send buffer + * fills. Writing and discarding the count silently drops the remainder: + * measured on this proxy before this existed, a 40 MB transfer arrived as + * 2.6 MB through the proxy alone and 11.9 MB through shim + proxy, while + * the same origin read directly delivered all 40 MB. Small responses fit in + * one buffer and never show it, which is why every test that pushed + * `hello` through passed. + * + * So: queue whatever the destination refused, flush it from the + * destination's own `drain`, and pause the *source* while a backlog exists + * so the queue tracks the slower end's pace instead of growing to the size + * of the transfer. `end()` is deferred until the queue has actually gone + * out — an upstream that closes right after a large body must not truncate + * what is still in flight to the client. + */ + function pump(target: Socket) { + const queue: Buffer[] = [] + const hold = (chunk: Buffer) => { + // Copied, not retained: the buffer handed to a `data` callback belongs + // to the caller for the duration of that call, and this outlives it. + queue.push(Buffer.from(chunk)) + held.source?.pause() + } + const held = { + /** The socket feeding this direction; paused while a backlog exists. */ + source: undefined as Socket | undefined, + ending: false, + send(chunk: Buffer) { + if (queue.length > 0) return hold(chunk) + const wrote = target.write(chunk) + if (wrote >= chunk.length) return + hold(chunk.subarray(Math.max(wrote, 0))) + }, + /** Drive from the target socket's `drain` handler, nowhere else. */ + flush() { + while (queue.length > 0) { + const head = queue[0]! + const wrote = target.write(head) + if (wrote < head.length) { + if (wrote > 0) queue[0] = head.subarray(wrote) + return + } + queue.shift() + } + held.source?.resume() + if (held.ending) target.end() + }, + end() { + held.ending = true + if (queue.length === 0) target.end() + }, + } + return held + } + + type Pump = ReturnType + + /** + * A client connection's progress through the proxy, tracked explicitly + * because `data` is async and Bun does not serialize its handlers: a second + * chunk re-enters `data` while the first is parked on `await Bun.connect`. + * Without a state set *before* that await, the re-entrant call finds no + * link yet, re-parses the same still-buffered head, and dials the origin a + * second time — measured: one client POST whose body followed the head by + * 1/5/10ms produced 2 upstream connections to a local origin and 4 to a + * real remote one, each carrying a duplicate of a non-idempotent request, + * with `toUpstream` left pointing at whichever dial resolved last. + * + * head — still reading the request head + * dialing — head parsed and allowed, upstream connect in flight + * linked — bytes flow both ways + * closed — denied, unreachable, or the client went away + * + * `closed` is what a dial in flight checks when it resolves, so a client + * that aborts mid-dial cannot strand an upstream socket nobody will ever + * close. + */ + type Phase = "head" | "dialing" | "linked" | "closed" + + /** What both bridges track per client connection. The shim has no head to + * read, so it simply starts at `dialing`. */ + type Link = { phase: Phase; toClient: Pump; toUpstream?: Pump } + + type Pending = Link & { buffer: string } + + const state = new WeakMap, Pending>() + + /** Mark a client gone and release whatever it owns. Setting the phase is + * what a dial still in flight sees when it resolves; without it, the + * socket that dial produces is owned by nobody — `close` has already run + * and found no link to tear down. */ + function shut(held?: Link) { + if (!held) return + held.phase = "closed" + held.toUpstream?.end() + } + + /** Read the phase through a call, not a comparison in place: assigning + * `dialing` earlier in the same scope narrows the property to that literal, + * and the compiler has no way to know `shut` can change it while an + * `await` is parked — which is the entire point of asking. */ + const gone = (held: Link) => held.phase === "closed" + + const refuse = (status: string, reason: string) => + `HTTP/1.1 ${status}\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n${reason}\n` + + const deny = (reason: string) => refuse("403 Forbidden", reason) + + /** + * Sent only by the TCP/loopback listener (darwin's seatbelt path — see + * `egress-runtime.ts`). A unix socket's access control is the filesystem + * permissions on the path itself; a loopback TCP port has none — every + * process on the machine can dial it — so that listener additionally + * requires a `Proxy-Authorization` header carrying a secret generated once + * per proxy start, and refuses (without forwarding anything) a request + * missing it or carrying the wrong one. `Proxy-Authenticate` names the + * scheme per RFC 7235. + */ + const unauthorized = () => + `HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="os"\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\nMissing or invalid Proxy-Authorization\n` + + const latin1 = (text: string) => Buffer.from(text, "latin1") + + /** + * How much of a request head to accept before refusing the connection. + * + * The head phase has no natural end other than `\r\n\r\n`, so a client that + * never sends one is buffered without limit — and because no dial is + * attempted on that path, nothing downstream bounds it either. Measured + * against this proxy before this cap existed: 93 MiB of never-terminated + * head took the *host* process from 36 MB to 1.34 GB of RSS in 8 seconds, + * and it was still climbing when the client stopped. + * + * 64 KiB is Squid's `request_header_max_size` default — the most generous of + * the conventional caps (nginx `large_client_header_buffers` 8k, Apache + * `LimitRequestFieldSize` 8190, Node `--max-http-header-size` 16 KiB) and the + * closest analogue, Squid being a forward proxy that speaks CONNECT. The + * clients here are pip, curl and requests, whose heads run 200-600 bytes, so + * this cannot plausibly refuse a real one. + */ + const HEAD_LIMIT = 64 * 1024 + + /** + * How long to wait for an upstream TCP connect before giving up. + * + * Linux retries a SYN for ~130 s by default, so an allowlisted host that + * black-holes packets — a firewall that drops rather than rejects — pins the + * client connection and its fd for over two minutes and then fails with no + * explanation. 30 s is far above any real handshake, which costs one RTT + * plus name resolution, and turns that wait into a legible 504. + */ + const DIAL_TIMEOUT = 30_000 + + type ServeProxyCommon = { rules: Rule[]; onEvent?: (line: string) => void; dialTimeout?: number } + + /** + * Host side. Proxies only allowlisted hosts. Listens on a unix socket + * (bubblewrap, Linux) or directly on a loopback TCP port with a required + * `secret` (seatbelt, macOS — see the module doc comment and + * `egress-runtime.ts`). Overloaded, not one union signature, so each call + * site gets back the concrete `UnixSocketListener`/`TCPSocketListener` its + * own input shape implies — `egress-runtime.ts`'s seatbelt path reads + * `.port` off the result, which only `TCPSocketListener` has. + * `dialTimeout` overrides `DIAL_TIMEOUT`; it exists so the timeout can be + * exercised in milliseconds rather than by making a test wait half a + * minute for the real one. + */ + export function serveProxy(input: ServeProxyCommon & { socket: string }): Bun.UnixSocketListener + export function serveProxy( + input: ServeProxyCommon & { hostname: string; port: number; secret: string }, + ): Bun.TCPSocketListener + export function serveProxy( + input: ServeProxyCommon & ({ socket: string } | { hostname: string; port: number; secret: string }), + ) { + const log = input.onEvent ?? (() => {}) + const budget = input.dialTimeout ?? DIAL_TIMEOUT + // Set only by the TCP/loopback listener — see `unauthorized` above for why. + const authorization = + "secret" in input ? `Basic ${Buffer.from(`os:${input.secret}`).toString("base64")}` : undefined + + // Branched, and the handlers built once and passed to whichever branch + // fires, rather than a spread of the two option shapes into one object: + // Bun.listen is overloaded on unix vs hostname/port, and a union spread + // matches neither overload (the same reason this file's own tests branch + // Bun.connect for the mirror image of this call). + const listen = (socket: SocketHandler) => + "socket" in input + ? Bun.listen({ unix: input.socket, socket }) + : Bun.listen({ hostname: input.hostname, port: input.port, socket }) + + return listen({ + open(client) { + state.set(client, { buffer: "", phase: "head", toClient: pump(client) }) + }, + async data(client, chunk) { + const held = state.get(client) + if (!held) return + if (held.phase === "linked") { + held.toUpstream?.send(chunk) + return + } + if (held.phase === "closed") return + + // Everything before the link is one buffer, so body bytes that land + // while the dial is in flight are simply still here when it + // resolves — `rest` is sliced after the await, not before it. + held.buffer += chunk.toString("latin1") + if (held.phase === "dialing") return + const end = held.buffer.indexOf("\r\n\r\n") + if (end === -1) { + if (held.buffer.length <= HEAD_LIMIT) return + // Fail closed. Unlike the dial window below there is no + // backpressure to apply here: the terminator is what the parse is + // waiting for, so refusing to read simply deadlocks the connection + // instead of ending it. A head this long is a protocol error. + log(`OVERSIZE ${held.buffer.length} bytes of head with no terminator`) + held.phase = "closed" + held.buffer = "" + held.toClient.send( + latin1(refuse("431 Request Header Fields Too Large", `Proxy request head exceeded ${HEAD_LIMIT} bytes`)), + ) + held.toClient.end() + return + } + + const head = held.buffer.slice(0, end) + const lines = head.split("\r\n") + const request = lines[0] ?? "" + const [method, target, version = "HTTP/1.1"] = request.split(" ") + + // Checked before anything about the request is even inspected for + // validity — an unauthenticated caller learns nothing about + // whether its target was well-formed, let alone allowlisted. + if (authorization) { + const header = lines.slice(1).find((line) => /^proxy-authorization:/i.test(line)) + const provided = header?.slice(header.indexOf(":") + 1).trim() + if (provided !== authorization) { + log(`AUTH missing or invalid Proxy-Authorization`) + held.phase = "closed" + held.buffer = "" + held.toClient.send(latin1(unauthorized())) + held.toClient.end() + return + } + } + + // CONNECT host:443 for TLS; absolute-form GET http://host/path for plain. + const url = + method === "CONNECT" + ? undefined + : (() => { + try { + return new URL(target) + } catch { + return undefined + } + })() + const authority = method === "CONNECT" ? target : url?.host + + if (!authority) { + log(`malformed ${request.slice(0, 60)}`) + held.phase = "closed" + held.toClient.send(latin1(deny("Malformed proxy request"))) + held.toClient.end() + return + } + + if (!allowed(authority, input.rules)) { + log(`DENY ${authority}`) + held.phase = "closed" + held.toClient.send(latin1(deny(`Host not on the sandbox allowlist: ${authority.split(":")[0]}`))) + held.toClient.end() + return + } + + const [hostname, port] = authority.split(":") + // Claim the dial before yielding. Everything above this line is + // synchronous, so no second chunk can be part-way through the same + // parse when it runs. + held.phase = "dialing" + // Backpressure, not a buffer limit. Everything the client sends + // while the dial is in flight would otherwise be held here, and a + // dial can be slow for as long as the OS retries a SYN: measured + // against a black-holed allowlisted origin, 8 seconds of blasting + // took the host process from 36 MB to 2.12 GB and then killed it + // outright with `RangeError: Out of memory` — and this proxy runs in + // the CLI's own process, so that is the supervisor dying at the hands + // of the thing the sandbox exists to contain. + // + // Pausing costs nothing and has no arbitrary limit: the bytes wait in + // the client's own socket buffer, and then in the client. Round 3 + // declined to do this on the grounds that "pausing the client is what + // would stop the FIN that tells us it left" — that is not so, and was + // never measured. A paused socket still reports its peer's departure: + // with delivery demonstrably stopped (0.21 MiB through a paused + // socket against 256 MiB through an unpaused one), the peer's `end()` + // still produced `close` while the pause was in force, for both FIN + // and RST. + client.pause() + // An allowlisted host that black-holes packets otherwise holds this + // connection for the kernel's whole SYN-retry budget. The phase is + // what makes this safe to fire late: `closed` is exactly what the + // dial below checks when it finally resolves, so the socket it + // produces is still ended by nobody-owns-it handling rather than + // stranded. + const timer = setTimeout(() => { + if (held.phase !== "dialing") return + log(`TIMEOUT ${authority}`) + held.phase = "closed" + held.buffer = "" + client.resume() + held.toClient.send(latin1(refuse("504 Gateway Timeout", `Timed out connecting to ${authority}`))) + held.toClient.end() + }, budget) + const upstream = await Bun.connect({ + hostname, + port: Number(port ?? (method === "CONNECT" ? 443 : 80)), + socket: { + data(_sock, payload) { + held.toClient.send(payload) + }, + drain() { + held.toUpstream?.flush() + }, + close() { + held.toClient.end() + }, + error() { + held.toClient.end() + }, + }, + }).catch(() => undefined) + clearTimeout(timer) + + // The client can have gone away while the dial was in flight — or the + // dial can have timed out above — in which case this is the only + // place that can release the socket it just produced. + if (gone(held)) { + upstream?.end() + return + } + + if (!upstream) { + log(`FAIL ${authority}`) + held.phase = "closed" + held.buffer = "" + client.resume() + held.toClient.send(latin1(deny(`Cannot reach ${authority}`))) + held.toClient.end() + return + } + + log(`ALLOW ${authority}`) + const toUpstream = pump(upstream) + toUpstream.source = client + held.toClient.source = upstream + held.toUpstream = toUpstream + held.phase = "linked" + // Sliced now rather than before the dial, so anything the client + // sent while it was in flight goes upstream in arrival order. + const rest = held.buffer.slice(end + 4) + held.buffer = "" + // Resumed before anything is forwarded, not after: `toUpstream` owns + // the client as its source from here, so a forward that has to queue + // re-pauses it through the pump. Resuming afterwards would undo that. + client.resume() + // CONNECT: acknowledge, then the client starts its TLS handshake. + // Plain HTTP: replay the request head we already consumed. + if (method === "CONNECT") { + held.toClient.send(latin1("HTTP/1.1 200 Connection Established\r\n\r\n")) + if (rest) toUpstream.send(latin1(rest)) + return + } + + // A proxy must rewrite absolute-form to origin-form. Forwarding + // `GET http://pypi.org/simple/ HTTP/1.1` verbatim is legal per RFC 7230 + // §5.3.2 but origin servers routinely reject it — measured: 403 from + // pypi.org on the plain-HTTP path while CONNECT to the same host + // returned 200. Also drop hop-by-hop `Proxy-*` headers, which are for + // us and must not travel upstream. + const origin = `${url!.pathname}${url!.search}` || "/" + const headers = lines + .slice(1) + .filter((line) => !/^proxy-/i.test(line)) + .filter((line) => !/^host:/i.test(line)) + const rewritten = [`${method} ${origin} ${version}`, `Host: ${url!.host}`, ...headers].join("\r\n") + toUpstream.send(latin1(`${rewritten}\r\n\r\n${rest}`)) + }, + drain(client) { + state.get(client)?.toClient.flush() + }, + close(client) { + shut(state.get(client)) + state.delete(client) + }, + error(client) { + shut(state.get(client)) + state.delete(client) + }, + }) + } + + /** + * Sandbox side. pip, requests and curl take `http://host:port` from + * HTTP_PROXY — none of them speak unix-socket proxies — so a loopback + * listener inside the namespace forwards raw bytes to the bind-mounted + * socket. + */ + export function serveShim(input: { port: number; socket: string }) { + // `pending` is for the window *before* the link exists — distinct from the + // backpressure queue inside `pump`, which is for after it does. `open` is + // async, so a client that writes immediately — curl sends CONNECT the + // moment the TCP handshake completes — arrives before the upstream link + // exists. Without this buffer those bytes are dropped and the connection + // hangs: the listener accepts, nothing is ever forwarded, and the client + // times out with the socket showing LISTEN the whole time. + // + // It is now a safety net rather than the main path: `open` pauses the + // client before it yields, so in practice nothing is delivered into + // `pending` at all. Keeping it costs nothing and is what stops a byte from + // being dropped should anything ever slip through ahead of the pause — + // dropping one here does not fail loudly, it hangs the connection. + // + // The `closed` phase covers the mirror image: a client that goes away + // *during* that same window. Its `close` runs while `toUpstream` is still + // undefined, so it has nothing to tear down, and the socket the dial then + // produces is owned by nobody. Measured on 300 connect-then-immediately- + // close connections, that stranded one fd per connection in this process + // and one in the host proxy on the other end of it — and a kernel or a + // terminal is a sandbox that lives for hours. + type Bridge = Link & { pending: Buffer[] } + const links = new WeakMap, Bridge>() + + return Bun.listen({ + hostname: "127.0.0.1", + port: input.port, + socket: { + async open(client) { + const held: Bridge = { pending: [], phase: "dialing", toClient: pump(client) } + links.set(client, held) + // The same backpressure the host proxy applies around its own dial, + // and for the same reason: without it a client that starts blasting + // before the link exists is buffered in `pending` without limit. The + // blast radius is smaller here — the shim lives inside the sandbox, + // so it is the sandbox's own memory — but it is the same defect, and + // an unbounded shim would in any case hand the whole blast to the + // host proxy the moment the link came up. + client.pause() + const upstream = await Bun.connect({ + unix: input.socket, + socket: { + data(_sock, payload) { + held.toClient.send(payload) + }, + drain() { + held.toUpstream?.flush() + }, + close() { + held.toClient.end() + }, + error() { + held.toClient.end() + }, + }, + }).catch(() => undefined) + if (gone(held)) { + upstream?.end() + held.pending.length = 0 + return + } + if (!upstream) { + client.resume() + held.toClient.end() + return + } + const toUpstream = pump(upstream) + toUpstream.source = client + held.toClient.source = upstream + held.toUpstream = toUpstream + held.phase = "linked" + // Before the replay, for the reason given in `serveProxy`: a queueing + // send re-pauses the client through the pump, and resuming after + // would undo it. + client.resume() + for (const chunk of held.pending) toUpstream.send(chunk) + held.pending.length = 0 + }, + data(client, chunk) { + const held = links.get(client) + if (!held || gone(held)) return + if (held.toUpstream) return void held.toUpstream.send(chunk) + held.pending.push(Buffer.from(chunk)) + }, + drain(client) { + links.get(client)?.toClient.flush() + }, + close(client) { + shut(links.get(client)) + }, + error(client) { + shut(links.get(client)) + }, + }, + }) + } +} diff --git a/backend/cli/src/sandbox/sandbox.ts b/backend/cli/src/sandbox/sandbox.ts index ab1a6abe..c570b803 100644 --- a/backend/cli/src/sandbox/sandbox.ts +++ b/backend/cli/src/sandbox/sandbox.ts @@ -1,10 +1,14 @@ import path from "path" import os from "os" import fs from "fs" +import { createHash } from "crypto" import { spawn, spawnSync } from "child_process" import { lazy } from "@/util/lazy" import { Log } from "@/util/log" import { Shell } from "@/shell/shell" +import { Global } from "@/global" +import { Installation } from "@/installation" +import { SHIM_READY_MARKER } from "./egress-shim-marker" const log = Log.create({ service: "sandbox" }) @@ -32,15 +36,61 @@ const log = Log.create({ service: "sandbox" }) * exfiltration. */ export namespace Sandbox { - export type Backend = "seatbelt" | "bubblewrap" | "none" + export type Backend = "seatbelt" | "bubblewrap" | "appcontainer" | "none" export interface Policy { /** Absolute paths the sandboxed process may write to. */ writable: string[] /** Exact host files the sandboxed process must not be able to read. */ unreadable?: string[] - /** Whether the sandboxed process may reach the network. */ - network: boolean + /** How the sandboxed process may reach the network. */ + network: "deny" | "allowlist" | "allow" + /** + * Unix socket that is the only egress route on Linux — bubblewrap's + * `--unshare-net` severs everything else. Required when network is + * "allowlist" and the backend is bubblewrap. + */ + egress?: string + /** + * TCP loopback port that is the only egress route on macOS. Seatbelt has + * no network namespace to sever, so there is no socket to bind-mount — + * `seatbeltProfile` instead narrows `network-outbound` to this one port + * (see its doc comment). Required when network is "allowlist" and the + * backend is seatbelt; carried on `Policy` rather than read from ambient + * state so profile generation stays a pure function of its input. + */ + port?: number + /** + * The `Proxy-Authorization` secret the loopback proxy requires on macOS + * — a TCP port, unlike `egress`'s unix socket, carries no filesystem + * permissions of its own, so `plan`/`wrapArgv` embed this in the proxy + * URL (`http://os:@127.0.0.1:`) rather than pointing the + * sandboxed process at an unauthenticated one. Not consumed by + * `seatbeltProfile` itself — the profile only narrows the network layer + * to `port`; the secret is enforced by `Egress.serveProxy` on the other + * end. Set together with `port` or not at all (see `buildPolicy`). + */ + secret?: string + /** + * AppContainer profile name on Windows. Containment there is anchored to a + * package SID rather than a namespace or a profile document: the SID is + * derived from this name, filesystem ACEs are granted to it, and the broker + * pipe's DACL names it. Required when the backend is "appcontainer". + * + * Derived from the workspace rather than passed in, so the same project + * gets the same SID across runs — ACLs granted once stay meaningful, and + * `CreateAppContainerProfile` is idempotent given a stable name. + */ + profile?: string + /** + * Read-only paths to bind into the namespace after `--tmpfs /tmp`, so + * they stay reachable regardless of where they happen to live on the + * host — including under `/tmp`, which `--tmpfs /tmp` otherwise masks + * unconditionally, `--ro-bind / /` notwithstanding. Used for the egress + * shim's executable — in dev, the generated launcher and the bundle it + * runs — and the interpreter that launcher execs. + */ + readBind?: string[] } /** A ready-to-spawn argv: `spawn(file, args)` with no shell wrapping. */ @@ -52,11 +102,46 @@ export namespace Sandbox { /** User-facing config knobs (mirrors Config.Sandbox, kept dependency-free). */ export interface Options { enabled?: boolean - network?: "allow" | "deny" + network?: "deny" | "allowlist" | "allow" + /** + * Address of the only egress route, in whatever shape the resolved + * backend needs: a bind-mountable unix socket path for bubblewrap, or + * `":"` for seatbelt (`EgressRuntime.egressFor` is the one + * producer, and it returns one string either way). `buildPolicy` is what + * interprets this per backend, into `Policy.egress` or + * `Policy.port`/`Policy.secret` respectively. + */ + egress?: string allowWrite?: string[] onUnavailable?: "warn" | "error" | "allow" } + /** + * The `enabled`/`network` an `Options` resolves to — the one place that + * answers both questions, so `decide()` and `buildPolicy()` below and + * `EgressRuntime.egressFor()` (which has to precompute the socket that will + * become `options.egress` *before* either of them runs) can't quietly + * disagree on what "unset" means. They did: a missing `enabled` used to + * read as off in `decide()` and on in `egressFor()`, and a missing + * `network` used to read as `"allowlist"` in `buildPolicy()` and not in + * `egressFor()` — each divergence invisible from the five production + * callers, all of which pass an already-fully-resolved policy, but real for + * any caller that doesn't. + * + * A wholly missing `Options` stays off: `enabled` requires an explicit + * `true`, matching `decide()`'s existing contract (see the "no options → + * runs the raw command unchanged" test in sandbox.test.ts) — this module is + * dependency-free and does not itself default a caller into being + * sandboxed. `network` unset defaults to `"allowlist"`, matching + * `buildPolicy()` and `Config.trustedSandbox()`. + */ + export function resolved(options?: Options): { enabled: boolean; network: "deny" | "allowlist" | "allow" } { + return { + enabled: options?.enabled === true, + network: options?.network ?? "allowlist", + } + } + export interface Plan { /** Program to spawn. */ file: string @@ -69,6 +154,13 @@ export namespace Sandbox { backend: Backend /** One-time human-readable note (e.g. sandbox requested but unavailable). */ warning?: string + /** + * Proxy variables the caller must set on the child's env for the loopback + * shim to be used. Present only when the command was actually wrapped + * through the shim (bubblewrap, network "allowlist", a usable egress + * socket) — same condition as `Wrapped.env`. + */ + env?: Record } /** Result of wrapping a raw argv (used by the notebook/R kernels). */ @@ -80,6 +172,12 @@ export namespace Sandbox { sandboxed: boolean backend: Backend warning?: string + /** + * Proxy variables the caller must set on the child's env for the loopback + * shim to be used. Present only when the argv was actually wrapped through + * the shim (bubblewrap, network "allowlist", a usable egress socket). + */ + env?: Record } export class UnavailableError extends Error { @@ -117,12 +215,52 @@ export namespace Sandbox { if (!bin) return "none" return probeBubblewrap(bin) ? "bubblewrap" : "none" } + if (process.platform === "win32") { + // Probed, never assumed. `AppContainer.usable()` loads the DLLs and + // derives a SID — side-effect free, and it catches the failure that + // matters: FFI bindings that do not resolve, which would mean composing a + // sandbox that is never actually applied. Anything it cannot prove falls + // back to "none", which is the behaviour Windows had before this existed. + const { AppContainer } = require("./appcontainer") as typeof import("./appcontainer") + return AppContainer.usable() ? "appcontainer" : "none" + } return "none" }) - /** The sandbox backend usable on this machine right now, or "none". */ - export function backend(): Backend { - return detected() + /** + * The sandbox backend for `platform`, defaulting to this machine's real + * one right now. + * + * For the default (or an explicitly-matching) `platform` this is exactly + * `detected()` — cached, and probed for real (`Bun.which`, + * `probeBubblewrap`) — so every existing zero-arg caller is unaffected. + * + * An explicitly *different* platform is the seam that lets the seatbelt + * code paths in `plan`/`wrapArgv`/`EgressRuntime` be exercised from Linux, + * where no Mac exists to install `sandbox-exec` on or probe for: probing a + * binary that cannot be present on the machine actually running the test + * would just report "none" and defeat the whole point. So a mismatched + * platform skips probing and assumes the backend that platform normally + * has — `sandbox-exec` ships with every macOS install, `bwrap` is what the + * real Linux branch above already probes for — trading "verified installed + * here" for "what plan()/wrapArgv() would compose for that platform", + * which is the property these tests actually need. + */ + export function backend(platform: NodeJS.Platform = process.platform): Backend { + if (platform === process.platform) return detected() + if (platform === "darwin") return "seatbelt" + if (platform === "linux") return "bubblewrap" + // An INJECTED win32 resolves to "appcontainer" so the Windows composition + // can be built and tested from a machine that is not Windows, exactly as + // the seatbelt paths were built from Linux. + // + // `detected()` above deliberately still answers "none" on a real Windows + // machine, and must keep doing so until the launcher exists. Flipping the + // live probe first would make `available()` true and have the product claim + // a sandbox it cannot actually apply — strictly worse than today's honest + // refusal to run kernels there. + if (platform === "win32") return "appcontainer" + return "none" } export function available(): boolean { @@ -174,6 +312,16 @@ export namespace Sandbox { return [...out] } + /** + * True when `p` is exactly `root` or lies inside it. Both arguments must + * already be `dedupe()`-normalized (`path.resolve()`d) — this does exact + * string comparison, the same convention `tooBroadToConfine` uses for the + * same reason. + */ + function isWithin(p: string, root: string): boolean { + return p === root || p.startsWith(root + path.sep) + } + /** * A path too broad to ever be a sandbox writable root: granting write here * would hand back most of the filesystem and defeat containment. Guards @@ -205,12 +353,35 @@ export namespace Sandbox { return roots.includes(p) } - /** Assemble the writable allowlist for a policy, dropping over-broad roots. */ + /** + * Assemble the writable allowlist for a policy, dropping over-broad roots, + * and route `options.egress` to whichever of `Policy.egress`/`Policy.port` + * the resolved `backend` actually consumes. + * + * `backend` is required (not read from ambient state) for the same reason + * `plan`/`wrapArgv` take a `platform` parameter: it is what makes the + * seatbelt branch here exercisable from Linux, and it is also simply + * correct — the caller already resolved it before deciding whether to + * sandbox at all, and re-deriving it here from `process.platform` would + * silently disagree with that decision on an injected platform. + */ function buildPolicy(input: { workspace: string[] extraWritable?: string[] + /** + * Paths that must be *visible* inside the sandbox but stay read-only. + * + * Distinct from `extraWritable` on purpose. A managed package environment + * has to be readable — the kernel executes its interpreter — but making it + * writable would let arbitrary kernel code install into it directly + * (`subprocess.run([sys.executable, "-m", "pip", "install", ...])` over the + * same allowlisted egress), reopening through the notebook tool exactly the + * bypass the bash-tool refusal closes. + */ + readable?: string[] unreadable?: string[] options: Options + backend: Backend }): Policy { const candidates = dedupe([ ...input.workspace, @@ -225,11 +396,69 @@ export namespace Sandbox { } return true }) - return { - writable, - unreadable: dedupe(input.unreadable ?? []).filter((value) => !tooBroadToConfine(value)), - network: (input.options.network ?? "allow") !== "deny", + const unreadable = dedupe(input.unreadable ?? []).filter((value) => !tooBroadToConfine(value)) + const network = resolved(input.options).network + + // Seatbelt's egress route is a bare TCP loopback port plus the + // `Proxy-Authorization` secret that port requires (see seatbeltProfile + // and Options.egress's doc comment), not a filesystem path — + // options.egress here is ":", and none of the path + // machinery below (dedupe's path.resolve, tooBroadToConfine) applies to + // it: resolving "52341:abc" against cwd would silently turn it into an + // absolute path and corrupt it. Port and secret are validated and + // dropped together — a port with no secret would compose a proxy URL + // seatbelt's own proxy always rejects, which is a confusing way to fail + // compared to the same "allowlist requires an egress port" throw a wholly + // missing value already produces (seatbeltProfile is the fail-closed + // enforcement point, the same division of labour bubblewrapArgs already + // has with the path branch immediately below). + if (input.backend === "seatbelt") { + const raw = input.options.egress + const at = raw?.indexOf(":") ?? -1 + const port = at > 0 ? Number(raw!.slice(0, at)) : undefined + const secret = at > 0 ? raw!.slice(at + 1) : undefined + const valid = port !== undefined && Number.isInteger(port) && port > 0 && !!secret + if (raw !== undefined && !valid) { + // Only the port half, never the secret: this is a warning, not an + // error path guarded by anything that stops it reaching a log + // sink — logging the credential half here would defeat the whole + // point of requiring one. + log.warn("refusing to grant sandbox egress access to an invalid port/secret pair", { + port: at > 0 ? raw!.slice(0, at) : raw, + }) + } + return { writable, unreadable, network, ...(valid ? { port, secret } : {}) } + } + + // dedupe() applies the same path.resolve() normalization used for + // writable/unreadable above, so a trailing slash, a double slash, or an + // unresolved ".." can't slip an over-broad path past tooBroadToConfine's + // AppContainer's egress is a named pipe, identified by a NAME rather than a + // filesystem path (`\\.\pipe\` is a namespace of its own, not a + // directory). It must skip the path machinery below for exactly the reason + // seatbelt's port:secret does: `dedupe`'s `path.resolve` would silently + // rewrite `openscience-broker-abc` into an absolute path under the current + // directory, and the launcher would then ask for a pipe nobody serves. + if (input.backend === "appcontainer") { + const pipe = input.options.egress?.trim() + return { + writable, + unreadable, + network, + profile: appContainerProfile(input.workspace), + ...(pipe ? { egress: pipe } : {}), + } } + + // string checks — the two normalization paths cannot drift apart because + // this is the exact same helper, not a parallel implementation of it. + const [egress] = dedupe(input.options.egress ? [input.options.egress] : []) + const egressOk = egress !== undefined && !tooBroadToConfine(egress) + if (egress !== undefined && !egressOk) { + log.warn("refusing to grant sandbox egress access to an over-broad path", { path: egress }) + } + const readBind = dedupe(input.readable ?? []).filter((value) => !tooBroadToConfine(value)) + return { writable, unreadable, network, ...(readBind.length ? { readBind } : {}), ...(egressOk ? { egress } : {}) } } // ── macOS: Seatbelt (sandbox-exec) ────────────────────────────────────────── @@ -250,9 +479,83 @@ export namespace Sandbox { return [...out] } + /** + * `(deny network*)` then, for "allowlist" only, a narrow re-allow scoped + * to exactly one loopback port — the host-side proxy `EgressRuntime` + * starts for seatbelt (see egress-runtime.ts). Seatbelt has no network + * namespace to sever the way bubblewrap's `--unshare-net` does, so there + * is no unix socket to bind-mount either: the profile itself is the only + * boundary, which is why the deny must always precede the allow (an + * allow with no prior deny is the unfiltered, unrestricted-egress shape + * this function must never produce) and why a missing, non-positive, or + * out-of-range port throws rather than silently falling back to a bare + * deny — the same fail-closed rule `bubblewrapArgs` applies to a missing + * egress socket. Falling back to a plain deny instead of throwing would + * look identical to a user asking for `network: "deny"`, which is not + * what "allowlist" means and is exactly the kind of silent downgrade this + * branch exists to avoid. + * + * Three allow lines, not one: `docs/adr/0002-sandbox-network-policy.md` + * records the reference implementation + * (`anthropic-experimental/sandbox-runtime`) as permitting + * `network-bind`/`network-inbound`/`network-outbound`, all narrowed to the + * proxy's loopback port, filter spelled `tcp` — not the single + * `network-outbound` with `(remote ip ...)` this function emitted before + * Task 7's fix round 1. That original, narrower shape was never measured; + * it was this function's author's own guess at what a TCP `connect()` + * needs, and a Task 7 review flagged the failure mode a wrong guess + * produces here: if seatbelt classifies the implicit local port a + * `connect()` allocates under `network-bind` (this sandboxed process is + * never a listener, so `network-inbound` is included for the same + * uncertainty, not because a genuine inbound connection is expected), a + * profile missing that allow would make "allowlist" unreachable on every + * real Mac — silently, indistinguishable from the network simply being + * down, which is the one direction this task must not ship in. Matching a + * documented, cited-as-working reference is the safer default than an + * independently-derived narrower profile that has never been measured + * against a real `sandbox-exec`. `local`/`remote` for `network-bind`+ + * `network-inbound` vs `network-outbound` follows ordinary SBPL + * convention (bind/inbound describe the local endpoint, outbound the + * remote one) — the ADR does not itself quote a filter spelling for the + * first two, only for `network-outbound`, so that pairing is this + * function's own inference, not a documented fact. See the Task 7 + * report's unverified section: whether seatbelt needs `network-bind`/ + * `network-inbound` at all, and whether `local`/`remote` is the right + * pairing for them, are both open questions only a Mac can answer. + * + * A narrow, accepted consequence of matching that reference shape (Task 7 + * fix round 2): if the host proxy dies while the sandboxed child is still + * alive, `network-bind`+`network-inbound` on that same ephemeral port + * would let the child itself bind or listen there. That is still confined + * to the one port this profile names — not a broader network grant, and + * not a route to any host the child couldn't already reach through the + * (now-dead) proxy — so it is not treated as a defect. It is a real + * property of this design, not a hypothetical one, and belongs next to + * the other open questions above rather than being silently true. + * + * Never asserts enforcement — that a real `sandbox-exec` actually honours + * this text — only the text itself, its ordering, and this function's own + * refusal to emit an unfiltered allow. No Mac exists on this project to + * verify the former; see the Task 7 report for exactly what a Mac owner + * still needs to run. + */ export function seatbeltProfile(policy: Policy): string { const lines = ["(version 1)", "(allow default)"] - if (!policy.network) lines.push("(deny network*)") + // No namespace equivalent on macOS, so "allowlist" cannot be enforced by + // severing the network device the way bubblewrapArgs does. Deny is the + // safe reading of a request for bounded egress; the allow lines below + // narrow that back to exactly the loopback proxy port when one reached + // the policy. + if (policy.network !== "allow") lines.push("(deny network*)") + if (policy.network === "allowlist") { + const port = policy.port + if (typeof port !== "number" || !Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error("sandbox network 'allowlist' requires an egress port") + } + lines.push(`(allow network-bind (local tcp "localhost:${port}"))`) + lines.push(`(allow network-inbound (local tcp "localhost:${port}"))`) + lines.push(`(allow network-outbound (remote tcp "localhost:${port}"))`) + } const unreadable = withPrivateAliases(dedupe(policy.unreadable ?? [])) if (unreadable.length) { lines.push(`(deny file-read* ${unreadable.map((value) => `(literal "${sbpl(value)}")`).join(" ")})`) @@ -274,11 +577,15 @@ export namespace Sandbox { // Whole fs read-only, a fresh /dev and /proc, and a throwaway writable /tmp; // then re-mount the bits that must be writable on top. const args = ["--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp"] - for (const p of dedupe(policy.writable)) { - // Skip only the /tmp mount root itself — it is provided as a fresh tmpfs and - // re-binding host /tmp would defeat it. A workspace that lives *under* /tmp - // still needs binding on top of the tmpfs, or its writes vanish. - if (p === "/tmp") continue + // "/tmp" itself is always in policy.writable (tempDirs() adds it unconditionally) + // but is deliberately never actually bound here — the fresh tmpfs above already + // provides it, and re-binding host /tmp would defeat that. So it's excluded from + // this set up front and shared with the readBind exclusion below: a path under + // "/tmp" is writable only where something *else* in policy.writable specifically + // covers it (e.g. a workspace that lives under /tmp), never merely because "/tmp" + // is nominally a writable root. + const boundWritable = dedupe(policy.writable).filter((p) => p !== "/tmp") + for (const p of boundWritable) { // --bind-try: don't abort if the source path doesn't exist. args.push("--bind-try", p, p) } @@ -291,27 +598,422 @@ export namespace Sandbox { if (!fs.existsSync(value)) continue args.push("--ro-bind-try", "/dev/null", value) } - if (!policy.network) args.push("--unshare-net") + if (policy.network !== "allow") args.push("--unshare-net") + if (policy.network === "allowlist") { + if (!policy.egress) throw new Error("sandbox network 'allowlist' requires an egress socket path") + // --unshare-net (above) is what makes this the only route to the network — + // there is no other network device inside the namespace. The --ro-bind here + // only makes the socket path reachable at all, for when it lives under a + // path the sandbox re-mounts (the /tmp tmpfs, a fresh /dev or /proc). + // Read-only, not read-write: the bind shares the host inode, so a + // sandboxed process could otherwise discover the path via + // /proc/self/mountinfo and `chmod 000` it, which persists on the host + // and disables egress for every other kernel/terminal/job sharing this + // one process-lifetime socket. A read-only bind blocks chmod (EROFS) + // while still permitting connect() — verified live: chmod fails with + // "Read-only file system" and a plain client still receives a reply + // over the same bind. + args.push("--ro-bind", policy.egress, policy.egress) + } + // Explicit, not a location choice: --tmpfs /tmp (above) masks the whole + // host /tmp subtree unconditionally, so anything under it — a generated + // launcher or bundle, the interpreter of a portable install — would + // otherwise silently not exist in here. Binding each path back in by its + // own name, after the tmpfs, is what makes it reachable regardless of + // where it actually lives on the host. + // + // Skip anything already inside a writable root — boundWritable specifically + // (not the raw policy.writable list), since that's what's actually mounted + // above; "/tmp" itself is nominally writable but was never bound, so it + // must not short-circuit this check (a launcher living under /tmp — e.g. + // Global.Path.bin redirected there by bun test's isolation — needs its + // own explicit bind same as anywhere else). bwrap mounts are applied in + // argument order and a later mount at (or inside) a path shadows whatever + // an earlier one put there — so a read-only bind here, coming after the + // --bind-try loop above, would silently turn part of an already-writable + // workspace read-only again wherever the two overlap. + // + // Only that one containment direction is guarded (a readBind path inside + // a writable root, not the reverse — a writable root nested inside a + // readBind path). The reason is narrow and specific to today's bind set, + // not a claim about what workspaces look like: writable roots are *not* + // only project roots (SessionFilesystem.processWriteRoots returns + // arbitrary user-granted paths, and options.allowWrite / extraWritable + // are arbitrary too), but every readBind path shimPlan() produces is a + // regular file — two generated artifacts under Global.Path.bin and the + // interpreter — and nothing can be nested inside a file, so the reverse + // direction has no trigger at all rather than an unlikely one. By the + // same token the exclusion changes nothing observable for today's set — + // re-binding a file the sandbox never writes read-only is a no-op — and + // is kept because the moment a directory joins that set, the shadowing + // above becomes reachable again. + // + // Order shadows the unreadable masks above too, which is the other reason + // to keep this set to individual files: a readBind *directory* containing + // an `unreadable` path re-exposes that path's real contents (measured: + // when this list still held the package root, a /dev/null mask on a file + // inside it was silently undone). Every entry here is a generated + // artifact or the interpreter, none of which is ever an unreadable + // candidate, so no mask can be defeated by name. + for (const p of dedupe(policy.readBind ?? [])) { + if (boundWritable.some((root) => isWithin(p, root))) continue + args.push("--ro-bind-try", p, p) + } // --unshare-pid: don't share the host PID namespace, so /proc//root of a // same-uid host process can't be used to write through the read-only bind. args.push("--unshare-pid", "--die-with-parent") return args } - /** Wrap an arbitrary argv under the active backend, or null when unavailable. */ - function specForArgv(argv: string[], policy: Policy): Spec | null { - switch (backend()) { + /** + * Wrap an arbitrary argv under `b`, or null when unavailable. `b` is + * passed in rather than read from `backend()` here — the caller already + * resolved it (via a possibly-injected `platform`), and re-deriving it + * from ambient state would silently disagree with that resolution. + */ + /** + * A stable AppContainer profile name for a workspace. + * + * Windows anchors containment to a package SID derived from this name, and + * the SID is what filesystem ACEs and the broker pipe's DACL refer to. So the + * name has to be stable across runs — a fresh name per launch would strand + * every ACE granted by the previous one — and distinct per project, so two + * projects cannot read each other's granted paths. + * + * Derived from the first workspace root rather than passed in, because the + * project id is not available this deep and the workspace already identifies + * the project uniquely. Hashed rather than embedded: a profile name is + * limited in length and character set, and a path contains separators, drive + * letters and spaces that are not valid in one. + */ + export function appContainerProfile(workspace: string[]): string { + const root = dedupe(workspace)[0] ?? "default" + return `openscience-${createHash("sha256").update(root).digest("hex").slice(0, 16)}` + } + + /** + * Argv that launches `argv` inside an AppContainer. + * + * Unlike bubblewrap and seatbelt there is no wrapper executable to exec: + * AppContainer confinement is applied AT process creation, through + * `SECURITY_CAPABILITIES` attributes passed to `CreateProcess`. That cannot be + * expressed as an argv, so the binary becomes its own launcher — exactly the + * pattern `__egress-shim` already uses at `index.ts:54`, and for the same + * reason: it needs no additional shipped artifact per architecture. + * + * The policy travels as one base64 blob rather than as flags. Windows command + * lines are re-parsed by `CommandLineToArgvW` with quoting rules that differ + * from every shell, and paths with spaces, quotes and backslashes are the norm + * there; a blob with no shell-significant characters cannot be mangled by + * them. The real argv still follows a `--` so the tail stays readable and + * matches the contract the other two backends keep. + */ + export function appContainerArgs(policy: Policy, argv: string[]): string[] { + if (!policy.profile) throw new Error("sandbox backend 'appcontainer' requires a profile name") + const spec = { + profile: policy.profile, + writable: policy.writable, + unreadable: policy.unreadable ?? [], + network: policy.network, + ...(policy.egress ? { pipe: policy.egress } : {}), + } + return ["__appcontainer-launch", Buffer.from(JSON.stringify(spec), "utf8").toString("base64"), "--", ...argv] + } + + function specForArgv(argv: string[], policy: Policy, b: Backend): Spec | null { + switch (b) { case "seatbelt": return { file: "sandbox-exec", args: ["-p", seatbeltProfile(policy), ...argv] } case "bubblewrap": return { file: "bwrap", args: [...bubblewrapArgs(policy), "--", ...argv] } + case "appcontainer": + // The binary launches itself into the container; see appContainerArgs. + return { file: process.execPath, args: appContainerArgs(policy, argv) } default: return null } } + // ── egress shim composition ───────────────────────────────────────────────── + + /** POSIX single-quote escaping: close, insert an escaped quote, reopen. */ + const quote = (value: string) => `'${value.replaceAll("'", `'"'"'`)}'` + + /** + * The sandboxed process needs a proxy at a host:port, but the only route out + * is a unix socket. This backgrounds a loopback bridge inside the namespace, + * waits (bounded) for it to signal readiness, and then execs the real + * command — so the sandbox still holds exactly one long-lived process, and + * the real command doesn't get a proxy env pointing at a port nothing is + * listening on yet. + * + * The wait is a marker-file poll, not a network probe: a POSIX `/bin/sh` + * (dash/busybox, not bash) has no built-in way to test a TCP connection — + * bash's `/dev/tcp` isn't portable here and `nc`/`curl` aren't guaranteed + * present. + * + * *Why the granularity is chosen at run time.* Fractional `sleep` is a + * GNU/BSD coreutils extension, not POSIX, and some busybox builds reject it + * outright (`sleep: invalid interval`) — which, in a loop, would print an + * error line per iteration to the real command's own stderr (this wait runs + * in the foreground, unlike the backgrounded shim) and, worse, skip the + * wait entirely, since a failing `sleep` doesn't slow a loop down at all. + * So the interval is settled once, before the loop, by attempting a single + * fractional `sleep` with its stderr discarded: it either works, and the + * loop polls at 0.02s, or it fails instantly and everything falls back to + * the whole seconds POSIX guarantees. That probe is the only place a + * fractional interval is ever attempted, its diagnostic can't reach the + * command's stderr, and its cost isn't waste — it is time the shim needs + * anyway. + * + * *Why not whole seconds throughout, as this did before.* Measured shim + * readiness (fork/exec, bundle load, listener bound) is ~12ms — the + * 600ms–1.1s in Task 4's report predates bundling the shim entry. At + * whole-second granularity the first check therefore always lost and every + * spawn paid a flat second: n=8, `network: "allowlist"` 1006-1007ms against + * `deny` 3-4ms, on `sh -c true`, i.e. 335x for a command that never touches + * the network. Every `ls` and every `git status` the agent ran paid it. At + * 0.02s the same measurement is 24-25ms. + * + * *Why there is a wall-clock deadline and not just an iteration count.* The + * count alone (150 * 0.02, 3 * 1) only equals 3s where forking `sleep` is + * nearly free. It isn't everywhere: a macOS CI runner measured 17.1s for + * the 150-iteration loop — ~114ms per iteration, of which ~94ms is + * fork/exec of `/bin/sleep`, a 5.7x overshoot of the documented cap. Any + * machine with expensive process creation (a CPU-throttled container, a + * loaded box) drifts the same way, so the loop carries an explicit deadline + * as well. `date +%s` is probed exactly like fractional `sleep` — a build + * without it leaves the deadline unset and the count is the only cap, which + * is the behaviour that shipped before — and `+ 4` rather than `+ 3` + * because `%s` truncates to whole seconds, which would otherwise cut a + * nominal 3s wait as short as 2.0s. + * + * The cap is therefore ~3s in both modes, and 3–4s when the deadline is the + * one that fires. If the shim never signals, the loop still exits at the cap + * and the real command runs anyway — against a closed proxy port, which + * fails fast and visibly (connection refused) rather than hanging forever. + */ + export function shimScript(input: { binary: string; port: number; socket: string; file: string; args: string[] }) { + const shim = [quote(input.binary), "__egress-shim", String(input.port), quote(input.socket)].join(" ") + const real = [quote(input.file), ...input.args.map(quote)].join(" ") + const marker = quote(SHIM_READY_MARKER) + // `s`/`n`/`i`/`d`/`t` are plain shell variables, never exported, and + // `exec` replaces this shell — so none of them reach the real command. + // `${t:-0}` keeps a `date` that starts failing mid-loop from breaking out + // early or printing to the real command's stderr: it degrades to the + // count-only cap, the same direction the probe failing does. + const wait = [ + `s=0.02; n=150`, + `sleep "$s" 2>/dev/null || { s=1; n=3; }`, + `d=$(date +%s 2>/dev/null); case "$d" in ''|*[!0-9]*) d= ;; *) d=$((d + 4)) ;; esac`, + `i=0; while [ ! -f ${marker} ] && [ "$i" -lt "$n" ] && { [ -z "$d" ] || { t=$(date +%s 2>/dev/null); [ "\${t:-0}" -lt "$d" ]; }; }; do sleep "$s"; i=$((i + 1)); done`, + ].join("; ") + return `${shim} >/dev/null 2>&1 & ${wait}; exec ${real}` + } + + /** + * Bubblewrap-only. Loopback port the shim binds inside the sandboxed + * network namespace. Fixed rather than negotiated: `--unshare-net` gives + * every sandboxed process its own private namespace, so this port can + * never collide across sandboxed processes or with anything on the host. + * Exported so `egress-runtime.ts` can hand it back to callers alongside + * the proxy's socket — one source of truth, rather than a second + * module-private 3128 that could drift from this one. + * + * Seatbelt has no such namespace — every process on the machine shares one + * loopback, so a fixed well-known port would collide across concurrent + * sandboxed processes the way it structurally cannot here. Its egress port + * (`Policy.port`) is instead assigned by the OS per proxy instance; see + * `egress-runtime.ts`. + */ + export const SHIM_PORT = 3128 + + /** + * Write one of the dev shim's generated artifacts, idempotently. Callers + * pass a content-addressed name, so a file already at that name already has + * this content and there is nothing to do; comparing anyway costs a few KB + * and repairs a truncated leftover. The write goes to a per-process + * temporary name and is renamed into place, which is atomic within a + * directory — two processes generating the same artifact concurrently write + * byte-identical content, and no third process can observe a half-written + * file at the real name. + * + * A missing file (first run, a fresh worktree, a test tmpdir) is the + * expected case, not a failure, so read errors of any kind just mean "write + * it" and are swallowed separately from the write's own errors. Those fail + * loud with an actionable message rather than a raw EACCES/EROFS out of + * `wrapArgv`: network "allowlist" without a working shim is a security- + * relevant misconfiguration (the caller explicitly asked for bounded + * egress), not something to silently downgrade. + */ + function place(file: string, content: Buffer, mode: number) { + const current = (() => { + try { + return fs.readFileSync(file) + } catch { + return undefined + } + })() + if (current?.equals(content)) return + const temp = `${file}.${process.pid}` + try { + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(temp, content, { mode }) + fs.chmodSync(temp, mode) + fs.renameSync(temp, file) + } catch (e) { + fs.rmSync(temp, { force: true }) + throw new Error(`Could not write the dev egress shim to ${file}: ${e instanceof Error ? e.message : String(e)}`) + } + } + + /** + * The single executable `shimScript` execs as the loopback bridge, plus + * every read-only path that must be explicitly bound into the namespace + * (`Policy.readBind`, consumed by `bubblewrapArgs`) for that executable to + * actually be reachable from inside, regardless of where it lives on the + * host. + * + * In a compiled release `process.execPath` IS the openscience binary, so + * `openscience __egress-shim ...` runs directly — one self-contained file, + * no extra artifact ships. + * + * Under `bun run src/index.ts` in development no such file exists: + * `process.execPath` is `bun`, and `bun __egress-shim ...` is not a valid + * bun invocation (it needs an entry script too), while `shimScript`'s + * `binary` is a single shell word once quoted, so a two-word "bun " + * invocation cannot be smuggled through it. So dev *builds* the missing + * file: `bun build` bundles `egress-shim-entry.ts` (a sibling of this file) + * into one self-contained module, and a tiny `sh` launcher execs `bun` + * against it — the same trick `ensureAtlasBinDir` in + * `src/openscience/index.ts` uses to expose a package's JS entry as one + * executable path. The entry is that file and never `src/index.ts`, whose + * graph pulls in `Global` (an unguarded top-level file write) and a live + * models.dev fetch, both of which run before any argv check could skip + * them. A compiled binary has no separate entry to redirect to, so it + * still goes through `index.ts`'s `__egress-shim` check and still + * evaluates that graph — see the Task 4 report for what that leaves + * reachable there; restructuring `index.ts` so nothing runs before the + * check, for both modes, is a materially bigger change than this fix. + * + * *Why bundle instead of running the source.* `--tmpfs /tmp` masks the + * whole host `/tmp` subtree, `--ro-bind / /` notwithstanding, so every path + * the shim touches has to be bound back by name — and the set of paths + * *running a source file* touches is open-ended: the entry, its imports, + * their imports, and for an npm import both the `node_modules` symlink and + * its target, which in this bun workspace is the monorepo-root store, one + * level *above* the package root. Successive revisions of this function + * bound the paths their author thought of (the launcher, then this file's + * directory, then the interpreter and the whole package root) and each + * time missed one — the last of them that npm target, latent only because + * nothing in the graph resolves a package today. A + * bundle ends that class rather than extending the list: at run time bun + * opens the bundle and nothing else, so the bound set is closed by + * construction — launcher, bundle, interpreter — instead of having to keep + * pace with an import graph. + * + * *What that does not cover*, stated precisely because "no future edit can + * break this" is the claim that was false the last four times: `bun build` + * inlines statically resolvable imports only, so a runtime + * `import(expression)` reaching outside the bundle would still resolve + * against an unbound path (a literal `import("./x")` is inlined and fine); + * the bundle executes from `Global.Path.bin`, so anything resolving off its + * own `import.meta.dir`/`url` no longer lands in the source tree; and an + * import the bundler cannot inline fails the build here, loudly, at + * `wrapArgv` time instead of silently inside the sandbox. Import-time side + * effects stay forbidden for the separate reason in + * `egress-shim-entry.ts`'s own comment — bundling relocates that code, it + * does not stop it running. + * + * The fourth one escapes the framing rather than sitting inside it: a + * dependency that loads a *native binding* bundles cleanly and still + * resolves a path at run time. Measured with `bun-pty` as a probe — the + * build succeeds, the JS is inlined, and the output then carries + * `dlopen("….so")`, `import.meta.require` and `process.cwd`, with only + * `bun:ffi` left external. A `dlopen` argument is not an import specifier, + * so neither "bun opens the bundle and nothing else" nor the static test + * that enforces it covers this; such a dependency would need its shared + * library bound by name the way the artifacts are. + * + * *Where the artifacts live does not need to be "safe."* Earlier revisions + * tried to pick a location `--tmpfs /tmp` couldn't mask — `Global.Path.state`, + * then this file's own directory — and both were live-verified broken: + * `Global.Path.*` resolves under `os.tmpdir()` during `bun test` + * (`test/preload.ts` redirects every XDG dir there for isolation) and + * possibly for a real user with `$HOME` under `/tmp`; the repo checkout + * resolves under `/tmp` for a `git worktree add /tmp/...` (this repo's own + * workflow), a CI `mktemp -d` clone, or a container build. There is no + * location immune to both. The fix is the one `bubblewrapArgs` already uses + * for the egress socket: bind the exact path back in, explicitly, after + * `--tmpfs /tmp` — `--ro-bind-try`, not `--bind`, since these are executed + * and read, never written to, from inside. `process.execPath` is bound for + * the same reason and is a structurally separate input, not implied by + * binding the artifacts: a portable bun install, or `$HOME` under `/tmp`, + * puts the interpreter the launcher execs under the tmpfs too. + * + * Bubblewrap-only. Every one of the artifacts this produces exists to get + * a launcher into a severed network namespace and bind it back in by name + * — problems seatbelt does not have, since it has no namespace and the + * sandboxed process dials the loopback proxy directly (see + * `seatbeltProfile`). `plan()`/`wrapArgv()` only ever call this behind a + * `backend === "bubblewrap"` guard, so on darwin — real or + * platform-injected — this function, and everything it writes to + * `Global.Path.bin`, is never reached at all. + */ + const shimPlan = lazy((): { binary: string; bind: string[] } => { + if (!Installation.isLocal()) return { binary: process.execPath, bind: [process.execPath] } + const entry = path.resolve(import.meta.dir, "egress-shim-entry.ts") + const built = Bun.spawnSync([process.execPath, "build", "--target=bun", entry]) + if (!built.success) { + throw new Error(`Could not bundle the dev egress shim from ${entry}: ${built.stderr.toString().trim()}`) + } + // Content-addressed, not fixed names: a rebuilt bundle is a different + // file rather than an overwrite of the one another process may be + // executing, and a name that already exists already holds this exact + // content, by construction. The launcher's own digest covers the bundle's + // path, so a new bundle always produces a new launcher pointing at it — + // a stale pair cannot form. What makes the bytes differ is (source, bun, + // cwd): bun build writes cwd-relative module banners into the output, so + // the same source built from `backend/cli` and from anywhere else are + // different files. Only correctness is claimed here, not thrift — cwd is + // the dimension that varies per invocation, so it is also the one that + // drives how many of these accumulate. + const stamp = (value: Buffer) => createHash("sha256").update(value).digest("hex").slice(0, 16) + // .mjs, not .js: nothing should make bun's module-type detection for this + // file depend on a package.json above Global.Path.bin. + const bundle = path.join(Global.Path.bin, `egress-shim-dev-${stamp(built.stdout)}.mjs`) + const script = Buffer.from(`#!/bin/sh\nexec ${quote(process.execPath)} ${quote(bundle)} "$@"\n`) + const launcher = path.join(Global.Path.bin, `egress-shim-dev-${stamp(script)}.sh`) + place(bundle, built.stdout, 0o644) + place(launcher, script, 0o755) + return { binary: launcher, bind: [launcher, bundle, process.execPath] } + }) + // ── planning (consumed by the bash tool and the kernels) ──────────────────── + /** + * The `HTTP_PROXY`-shaped URL the sandboxed process should use, or + * `undefined` when nothing composed a route to the proxy at all. + * Bubblewrap: the shim's fixed `SHIM_PORT`, unauthenticated — the + * bind-mounted unix socket underneath it is already the sandboxed + * process's only route out, so the loopback hop inside the namespace + * needs no credential of its own. Seatbelt: no shim, so the sandboxed + * process dials `policy.port` directly, and — because that loopback port, + * unlike a unix socket, carries no filesystem permissions of its own — + * the URL embeds `policy.secret` as userinfo + * (`http://os:@127.0.0.1:`), which pip, curl and requests + * all parse into a `Proxy-Authorization` header. Both must be present, not + * just `port`: `buildPolicy` only ever sets them together, so a `port` + * with no `secret` means something upstream broke that invariant, and + * this fails closed to "no proxy configured" rather than emitting a URL + * seatbelt's own proxy would just reject with 407 anyway. + */ + function proxyUrl(shim: string | undefined, b: Backend, policy: Policy): string | undefined { + if (shim) return `http://127.0.0.1:${SHIM_PORT}` + if (b !== "seatbelt" || policy.network !== "allowlist" || !policy.port || !policy.secret) return undefined + return `http://os:${policy.secret}@127.0.0.1:${policy.port}` + } + // Warn only once per process so every command doesn't repeat the same notice. const warned = { unavailable: false } @@ -320,31 +1022,50 @@ export namespace Sandbox { } /** - * Resolve which backend a command should use given the config. Returns - * backend "none" (run unsandboxed) with an optional one-time warning, or the + * Resolve which backend a command should use given the config and + * `platform` (default the real one — see `backend()`). Returns backend + * "none" (run unsandboxed) with an optional one-time warning, or the * active backend. Throws UnavailableError only when `onUnavailable: "error"` * and no backend exists. */ - function decide(options?: Options): { backend: Backend; warning?: string } { - if (options?.enabled !== true) return { backend: "none" } - const b = backend() + function decide( + options: Options | undefined, + platform: NodeJS.Platform = process.platform, + ): { backend: Backend; warning?: string } { + if (!resolved(options).enabled) return { backend: "none" } + const b = backend(platform) if (b !== "none") return { backend: b } - const mode = options.onUnavailable ?? "warn" + const mode = options?.onUnavailable ?? "warn" if (mode === "error") throw new UnavailableError(unavailableMessage()) const warning = mode === "warn" && !warned.unavailable ? unavailableMessage() : undefined if (warning) { warned.unavailable = true - log.warn("sandbox enabled but unavailable", { platform: process.platform }) + log.warn("sandbox enabled but unavailable", { platform }) } return { backend: "none", warning } } /** * Decide how to run a shell command given the sandbox config and the - * workspace. Never throws unless `onUnavailable: "error"` and no backend - * exists. The `cwd` is *not* granted write access unless it lies within the - * workspace — an approved external working directory is a permission decision, - * not a reason to widen the write boundary to the escape target. + * workspace. Throws only in two cases: `onUnavailable: "error"` with no + * backend available, or `network: "allowlist"` with no `egress` socket path + * (directly, or because the supplied path was rejected as over-broad). The + * `cwd` is *not* granted write access unless it lies within the workspace — + * an approved external working directory is a permission decision, not a + * reason to widen the write boundary to the escape target. + * + * Composes the same loopback shim `wrapArgv` does, under the same + * condition (bubblewrap, network "allowlist", a usable egress socket) — + * `pip`/`curl`/`uv` run through here, not `wrapArgv`, so this is the path + * the feature's motivating case actually needs. `shimScript` already + * treats its `file`/`args` as an arbitrary argv to `exec`, so a shell + * invocation composes by feeding it `input.shell`/`["-c", input.command]` + * exactly as the no-shim branch below already passes to `specForArgv` — + * one shape, not a second implementation of "wrap a shell command". + * + * `platform` defaults to the real one — the only reason to pass a + * different value is to exercise the seatbelt/darwin branch from a + * machine that isn't one; see `backend()`. */ export function plan(input: { command: string @@ -353,15 +1074,48 @@ export namespace Sandbox { /** Workspace roots (Instance.directory + worktree) that stay writable. */ workspace: string[] options?: Options + platform?: NodeJS.Platform }): Plan { - const { backend: b, warning } = decide(input.options) + const platform = input.platform ?? process.platform + const { backend: b, warning } = decide(input.options, platform) if (b === "none") { return { file: input.command, useShell: input.shell, sandboxed: false, backend: "none", warning } } - const policy = buildPolicy({ workspace: input.workspace, options: input.options! }) - const s = specForArgv([input.shell, "-c", input.command], policy)! + const policy = buildPolicy({ workspace: input.workspace, options: input.options!, backend: b }) + // Bubblewrap's loopback shim bridges a bind-mounted unix socket that only + // exists inside its own network namespace. Seatbelt has no namespace, so + // there is nothing to bridge and no shim to compose — the sandboxed + // process instead dials the loopback proxy port seatbeltProfile allowed + // directly, which is why this guard stays bubblewrap-only rather than + // "any backend with allowlist + an egress value". + const shimmed = b === "bubblewrap" && policy.network === "allowlist" && policy.egress ? shimPlan() : undefined + const shim = shimmed + ? shimScript({ + binary: shimmed.binary, + port: SHIM_PORT, + socket: policy.egress!, + file: input.shell, + args: ["-c", input.command], + }) + : undefined + const argv = shim ? ["/bin/sh", "-c", shim] : [input.shell, "-c", input.command] + const s = specForArgv( + argv, + shimmed ? { ...policy, readBind: [...(policy.readBind ?? []), ...shimmed.bind] } : policy, + b, + )! log.info("sandboxing command", { backend: b, network: policy.network, writable: policy.writable.length }) - return { file: s.file, args: s.args, useShell: false, sandboxed: true, backend: b, warning } + const proxy = proxyUrl(shim, b, policy) + const env = proxy ? { HTTP_PROXY: proxy, HTTPS_PROXY: proxy, http_proxy: proxy, https_proxy: proxy } : undefined + return { + file: s.file, + args: s.args, + useShell: false, + sandboxed: true, + backend: b, + warning, + ...(env ? { env } : {}), + } } /** @@ -369,6 +1123,10 @@ export namespace Sandbox { * which spawn an interpreter directly. When the sandbox is off or unavailable * the original `file`/`args` are returned unchanged, so callers can spawn the * result verbatim. + * + * `platform` defaults to the real one — the only reason to pass a + * different value is to exercise the seatbelt/darwin branch from a + * machine that isn't one; see `backend()`. */ export function wrapArgv(input: { file: string @@ -377,23 +1135,41 @@ export namespace Sandbox { workspace: string[] /** Extra paths (e.g. a generated kernel script under /tmp) to keep writable/visible. */ extraWritable?: string[] + /** Paths that must be visible inside the sandbox but stay read-only. */ + readable?: string[] /** Exact host credential files to mask from the process. */ unreadable?: string[] options?: Options + platform?: NodeJS.Platform }): Wrapped { - const { backend: b, warning } = decide(input.options) + const platform = input.platform ?? process.platform + const { backend: b, warning } = decide(input.options, platform) if (b === "none") { return { file: input.file, args: input.args, sandboxed: false, backend: "none", warning } } const policy = buildPolicy({ workspace: input.workspace, extraWritable: input.extraWritable, + readable: input.readable, unreadable: input.unreadable, options: input.options!, + backend: b, }) - const s = specForArgv([input.file, ...input.args], policy)! + // Only bubblewrap's --unshare-net + --bind gives the shim anything to + // bridge: seatbelt has no namespace, so there is nothing to bridge and no + // shim to compose — the sandboxed process instead dials the loopback + // proxy port seatbeltProfile allowed directly (see plan()'s identical + // guard for the shell-command path). + const plan = b === "bubblewrap" && policy.network === "allowlist" && policy.egress ? shimPlan() : undefined + const shim = plan + ? shimScript({ binary: plan.binary, port: SHIM_PORT, socket: policy.egress!, file: input.file, args: input.args }) + : undefined + const argv = shim ? ["/bin/sh", "-c", shim] : [input.file, ...input.args] + const s = specForArgv(argv, plan ? { ...policy, readBind: [...(policy.readBind ?? []), ...plan.bind] } : policy, b)! log.info("sandboxing process", { backend: b, network: policy.network, writable: policy.writable.length }) - return { file: s.file, args: s.args, sandboxed: true, backend: b, warning } + const proxy = proxyUrl(shim, b, policy) + const env = proxy ? { HTTP_PROXY: proxy, HTTPS_PROXY: proxy, http_proxy: proxy, https_proxy: proxy } : undefined + return { file: s.file, args: s.args, sandboxed: true, backend: b, warning, ...(env ? { env } : {}) } } // ── self-test (proves the boundary actually holds on this machine) ────────── diff --git a/backend/cli/src/science/kernel/process.ts b/backend/cli/src/science/kernel/process.ts index 401ddb00..ed21fb25 100644 --- a/backend/cli/src/science/kernel/process.ts +++ b/backend/cli/src/science/kernel/process.ts @@ -5,6 +5,15 @@ import type { KernelProcess } from "./types" const hooks = new Set<() => void>() let hooked = false +/** + * The platform start token for a pid: field 19 of `/proc//stat` on Linux, + * `ps -o lstart=` on darwin. + * + * **Undefined on Windows**, which has neither branch, and undefined whenever + * the read fails. Callers must treat "no token" as "cannot distinguish pid + * reuse", never as "not running" — see `matches` and `running`, which both + * fall back to bare liveness in that case. + */ function token(pid: number) { if (process.platform === "linux") { try { @@ -46,6 +55,32 @@ export namespace KernelProcessIdentity { } } + /** The start token for a pid, or undefined where the platform cannot supply + * one. Exported so callers holding a persisted pid — an installer claim, say + * — can capture the same value `capture()` stores for a kernel. */ + export function startToken(pid: number) { + return token(pid) + } + + /** + * Liveness for a bare pid + token pair, the shape a persisted record has + * after a restart when no `ChildProcess` survives. + * + * Applies the same fallback rule as `matches`: when no token was captured — + * Windows, or a read that failed — liveness alone is sufficient. Demanding a + * token match there would report every Windows process as dead, which for + * the installer claim would mark every environment permanently suspect. + */ + export function running(pid: number, value?: string) { + try { + process.kill(pid, 0) + } catch { + return false + } + if (!value) return true + return token(pid) === value + } + export function matches(proc: ChildProcess, identity?: KernelProcess) { if (!identity || proc.pid !== identity.pid || proc.exitCode !== null) return false try { diff --git a/backend/cli/src/science/kernel/registry.ts b/backend/cli/src/science/kernel/registry.ts index 67af45cd..8d3bb49f 100644 --- a/backend/cli/src/science/kernel/registry.ts +++ b/backend/cli/src/science/kernel/registry.ts @@ -51,6 +51,17 @@ type Entry = { incarnation: number | null executionCount: number environment: KernelEnvironment | null + /** + * Directory of the managed package environment this kernel is bound to, or + * null for the host interpreter. + * + * Deliberately NOT the field above: `environment` is a `KernelEnvironment`, + * the kernel's runtime context (cwd, sandbox platform). Merging the two would + * bind kernels to the wrong thing. Deliberately not part of `KernelIdentity` + * either — that tuple is hashed into the storage key, so adding to it would + * orphan every persisted record. + */ + boundEnvironment: string | null startedAt: number | null lastActivityAt: number | null authority: ExecutionAuthority.Decision | null @@ -197,6 +208,7 @@ function restore(value: z.infer) { incarnation: value.incarnation, executionCount: value.execution_count, environment: null, + boundEnvironment: null, startedAt: null, lastActivityAt: value.last_activity_at, authority: null, @@ -240,6 +252,7 @@ const record = (identity: KernelIdentity) => { incarnation: null, executionCount: 0, environment: null, + boundEnvironment: null, startedAt: null, lastActivityAt: null, authority: null, @@ -352,14 +365,21 @@ async function provenance( ) } -const entry = async (identity: KernelIdentity, _options?: KernelStartOptions) => { +const entry = async (identity: KernelIdentity, options?: KernelStartOptions) => { const authority = await ExecutionAuthority.require({ projectID: identity.projectID, sessionID: identity.sessionID, capability: "kernel", }) const value = await hydrate(identity) - if (value.kernel?.ready && value.authority?.generation === authority.generation) return value + const bound = options?.environment ?? null + // Registry-level staleness, not authority-level: `ExecutionAuthority.require` + // takes no kernel identity, so `generation` cannot see which environment a + // given kernel is bound to. A live kernel pointed at a different environment + // is running the wrong interpreter, so it is torn down here on exactly the + // terms a generation change would use. + const rebind = value.boundEnvironment !== bound + if (value.kernel?.ready && value.authority?.generation === authority.generation && !rebind) return value if (value.kernel?.ready) { await value.manager.release(value.key) value.kernel = undefined @@ -382,6 +402,7 @@ const entry = async (identity: KernelIdentity, _options?: KernelStartOptions) => value.state = "stopped" value.kernel = undefined value.environment = null + value.boundEnvironment = bound value.incarnation = incarnation value.executionCount = 0 value.startedAt = null @@ -407,6 +428,7 @@ const entry = async (identity: KernelIdentity, _options?: KernelStartOptions) => return value.manager.get(value.key, { sessionID: identity.sessionID, cwd: authority.workspace, + ...(bound ? { environment: bound } : {}), }) })().then( async (kernel) => { @@ -627,6 +649,36 @@ export namespace KernelRuntime { return identity } + /** + * Restart every kernel bound to a package environment, leaving every other + * kernel untouched. Called only for a non-additive change: a module already + * loaded into a live interpreter stays at its old version in memory while the + * files on disk say otherwise, and a silently stale module is worse than an + * obvious restart. + * + * NOT to be confused with the entry's existing `environment` field, which is + * a `KernelEnvironment` — the kernel's runtime context (cwd, sandbox + * platform) and nothing to do with installed packages. The package binding is + * carried separately as `boundEnvironment` precisely to keep the two apart. + * + * `environment` is the environment *directory*, which is what a kernel binds + * to — the caller resolves the name through `Environment.directory` so this + * never has to know the project layout. + * + * Releasing rather than restarting in place is deliberate: kernels are lazy, + * so the next cell boots a fresh one against the new interpreter. Eagerly + * respawning here would pay the startup cost for kernels the session may + * never touch again. + */ + export async function restartEnvironment(projectID: string, environment: string) { + const values = Array.from(records().entries.values()).filter( + (value) => value.identity.projectID === projectID && value.boundEnvironment === environment, + ) + // Sequential, not Promise.all: `release` mutates the shared records map, + // and the set is small by construction — one project's live kernels. + for (const value of values) await release(value.identity) + } + export async function release(identity: KernelIdentity) { const value = records().entries.get(key(identity)) if (!value) return diff --git a/backend/cli/src/science/kernel/types.ts b/backend/cli/src/science/kernel/types.ts index c69ac480..974face2 100644 --- a/backend/cli/src/science/kernel/types.ts +++ b/backend/cli/src/science/kernel/types.ts @@ -31,8 +31,8 @@ export const KernelEnvironment = z.object({ sandbox: z.object({ requested: z.boolean(), enforced: z.boolean(), - backend: z.enum(["seatbelt", "bubblewrap", "none"]), - network: z.enum(["allow", "deny"]), + backend: z.enum(["seatbelt", "bubblewrap", "appcontainer", "none"]), + network: z.enum(["deny", "allowlist", "allow"]), platform: z.string(), available: z.boolean(), tool: z.string().optional(), @@ -99,6 +99,15 @@ export interface KernelStartOptions { env?: Record /** Interpreter binary override (e.g. a specific python/Rscript path). */ binary?: string + /** + * Directory of the managed package environment this kernel binds to. + * + * A start option, never part of `KernelIdentity`: putting it in the identity + * tuple would rekey every persisted record and orphan them. Distinct from + * `KernelEnvironment`, which is the kernel's *runtime* context (cwd, sandbox + * platform) and has nothing to do with installed packages. + */ + environment?: string } export interface KernelProcess { diff --git a/backend/cli/src/server/routes/settings/sandbox.ts b/backend/cli/src/server/routes/settings/sandbox.ts index 83b175d7..1bd356c8 100644 --- a/backend/cli/src/server/routes/settings/sandbox.ts +++ b/backend/cli/src/server/routes/settings/sandbox.ts @@ -10,7 +10,8 @@ const log = Log.create({ service: "settings-sandbox" }) const PatchSchema = z.object({ enabled: z.boolean().optional(), - network: z.enum(["allow", "deny"]).optional(), + network: z.enum(["deny", "allowlist", "allow"]).optional(), + allowHosts: z.array(z.string()).optional(), allowWrite: z.array(z.string()).optional(), onUnavailable: z.enum(["warn", "error", "allow"]).optional(), }) diff --git a/backend/cli/src/session/prompt.ts b/backend/cli/src/session/prompt.ts index 642352b7..8626b6ae 100644 --- a/backend/cli/src/session/prompt.ts +++ b/backend/cli/src/session/prompt.ts @@ -861,6 +861,7 @@ export namespace SessionPrompt { const system = [ ...(await SystemPrompt.environment(model)), ...(await SystemPrompt.compute()), + ...(await SystemPrompt.packages()), ...(await InstructionPrompt.system()), ...(SKILL_ROUTING_AGENTS.has(agent.name) ? [await SystemPrompt.availableSkills(agent.permission)] : []), ...artifactContext, diff --git a/backend/cli/src/session/system.ts b/backend/cli/src/session/system.ts index eb8b2ff4..1c54d779 100644 --- a/backend/cli/src/session/system.ts +++ b/backend/cli/src/session/system.ts @@ -8,6 +8,7 @@ import { Config } from "../config/config" import { Skill } from "../skill" import { PermissionNext } from "../permission/next" import { ComputePrompt } from "../compute/prompt" +import { PackagePrompt } from "../package/prompt" export namespace SystemPrompt { export function instructions() { @@ -22,6 +23,22 @@ export namespace SystemPrompt { return [await ComputePrompt.system(value)] } + /** + * Governed package installation. Injected unconditionally, for the same + * reason `compute()` is: it has to pre-empt every skill, reference file and + * third-party document that says `pip install`. + * + * 199 of the 293 shipped `SKILL.md` files mention `pip install`. Editing them + * would be neither necessary nor sufficient — reference files are never + * intercepted by the skill tool, and skills cloned from GitHub are not this + * repo's to edit. A block on every request reaches all of them. + */ + export async function packages(projectID?: string) { + // Defaults to the live project so the injection site stays a bare call; + // tests pass an explicit id (or omit it for the empty rendering). + return [await PackagePrompt.system(projectID ?? Instance.project.id)] + } + /** When the user message begins with `/` matching an installed * skill, the model should invoke the skill tool immediately and * silently — zero text output before the tool call. */ diff --git a/backend/cli/src/tool/bash.ts b/backend/cli/src/tool/bash.ts index b91dd33f..41db6f6b 100644 --- a/backend/cli/src/tool/bash.ts +++ b/backend/cli/src/tool/bash.ts @@ -16,7 +16,9 @@ import { Shell } from "@/shell/shell" import { BashArity } from "@/permission/arity" import { Truncate } from "./truncation" import { OpenScience } from "@/openscience" +import { Refuse } from "@/package/refuse" import { Sandbox } from "@/sandbox/sandbox" +import { EgressRuntime } from "@/sandbox/egress-runtime" import { SessionFilesystem } from "@/session/filesystem" import { Filesystem } from "@/util/filesystem" import { Provenance } from "@/science/provenance/store" @@ -196,6 +198,12 @@ export const BashTool = Tool.define("bash", async () => { command.push(child.text) } + // Before any ctx.ask, before the sandbox is composed, before anything + // runs: refusing after prompting would ask the user to approve a + // command that is then refused anyway. + const refusal = Refuse.installer(command) + if (refusal) throw new Error(refusal) + // not an exhaustive list, but covers most common cases if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", "cat"].includes(command[0])) { const operands = command @@ -277,18 +285,19 @@ export const BashTool = Tool.define("bash", async () => { // provider keys (auth.json + shell env), not just synced managed ones. await OpenScience.refreshByokSecrets(process.env).catch(() => {}) - const env = await OpenScience.subprocessEnv(process.env) // Wrap the command in the authority's effective OS-sandbox policy. The // permission checks above decide *whether* to run; this decides *with what // authority*. An explicit trusted machine-level opt-out returns the raw // command unchanged. + const egress = await EgressRuntime.egressFor(authority.sandbox) const sandbox = Sandbox.plan({ command: params.command, shell, cwd, workspace: writable, - options: authority.sandbox, + options: { ...authority.sandbox, egress }, }) + const env = { ...(await OpenScience.subprocessEnv(process.env)), ...(sandbox.env ?? {}) } const started = Date.now() const proc = sandbox.sandboxed diff --git a/backend/cli/src/tool/biology/notebook.ts b/backend/cli/src/tool/biology/notebook.ts index 94bd8ec8..66903234 100644 --- a/backend/cli/src/tool/biology/notebook.ts +++ b/backend/cli/src/tool/biology/notebook.ts @@ -8,6 +8,7 @@ import { Shell } from "@/shell/shell" import { Instance } from "@/project/instance" import { OpenScience } from "@/openscience" import { Sandbox } from "@/sandbox/sandbox" +import { EgressRuntime } from "@/sandbox/egress-runtime" import { ExecutionAuthority } from "@/project/execution" const KERNEL_SCRIPT = ` @@ -186,19 +187,21 @@ async function getKernel(sessionID: string): Promise { const pythonBin = await findPython() // Confine the kernel to the workspace when the execution sandbox is on: it runs // arbitrary agent-authored code — the same threat model as the bash tool. + const egress = await EgressRuntime.egressFor(authority.sandbox) const sandboxed = Sandbox.wrapArgv({ file: pythonBin, args: ["-u", scriptPath], workspace: authority.writable, extraWritable: [scriptPath, configPath, cachePath], unreadable: OpenScience.kernelSensitivePaths(), - options: authority.sandbox, + options: { ...authority.sandbox, egress }, }) const proc = spawn(sandboxed.file, sandboxed.args, { cwd: authority.workspace, env: { ...OpenScience.kernelEnv(process.env), ...OpenScience.pythonThreadCapEnv(process.env), + ...(sandboxed.env ?? {}), ATLAS_CLI_CONFIG_PATH: configPath, MPLCONFIGDIR: path.join(cachePath, "matplotlib"), XDG_CACHE_HOME: path.join(cachePath, "xdg"), diff --git a/backend/cli/src/tool/notebook.ts b/backend/cli/src/tool/notebook.ts index fd8f63de..c9fa2730 100644 --- a/backend/cli/src/tool/notebook.ts +++ b/backend/cli/src/tool/notebook.ts @@ -9,7 +9,10 @@ import { Instance } from "@/project/instance" import { OpenScience } from "@/openscience" import { Config } from "@/config/config" import { SessionFilesystem } from "@/session/filesystem" +import { Environment } from "@/package/environment" +import { Installer } from "@/package/installer" import { Sandbox } from "@/sandbox/sandbox" +import { EgressRuntime } from "@/sandbox/egress-runtime" import { KernelQueue } from "@/science/kernel/queue" import { KernelProcessIdentity } from "@/science/kernel/process" import { KernelRuntime } from "@/science/kernel/registry" @@ -198,7 +201,20 @@ interface RawPayload { execution_count: number } -async function findPython(override?: string): Promise { +/** + * The interpreter a kernel runs. A managed environment's own interpreter wins + * when it exists; otherwise the host's. + * + * The fallback is deliberate. Failing closed on a missing environment would + * make a typo'd name indistinguishable from a broken machine — the exact + * failure mode this design started from, where a missing pip, a severed + * network and a read-only site-packages all surfaced as one opaque error. + */ +export async function findPython(override?: string, environment?: string): Promise { + if (environment) { + const bin = Installer.interpreter(environment) + if (await Bun.file(bin).exists()) return bin + } const candidates = override ? [override] : ["python3", "python"] for (const bin of candidates) { try { @@ -283,7 +299,7 @@ class PythonKernel implements Kernel { this.configPath = configPath this.cachePath = cachePath - const bin = await findPython(opts?.binary) + const bin = await findPython(opts?.binary, opts?.environment) const workspace = opts?.sessionID ? await SessionFilesystem.processWriteRoots(opts.sessionID) : [Instance.directory, Instance.worktree] @@ -291,13 +307,21 @@ class PythonKernel implements Kernel { // notebook runs arbitrary agent-authored code — the same threat model as the // bash tool — so it must not be able to escape the boundary bash respects. const policy = await Config.trustedSandbox() + const egress = await EgressRuntime.egressFor(policy) const sandboxed = Sandbox.wrapArgv({ file: bin, args: ["-u", scriptPath], workspace, extraWritable: [scriptPath, configPath, cachePath], + // Read-only, never writable. The kernel executes this interpreter, so it + // must be visible — but a writable environment would let arbitrary kernel + // code install into it directly (subprocess pip over the same allowlisted + // egress), reopening through the notebook tool the bypass the bash-tool + // refusal closes. Explicit rather than relying on `--ro-bind / /`, which + // does not survive `--tmpfs /tmp` if the cache root ever lives there. + ...(opts?.environment ? { readable: [opts.environment] } : {}), unreadable: OpenScience.kernelSensitivePaths(), - options: policy, + options: { ...policy, egress }, }) const cwd = opts?.cwd ?? (opts?.sessionID ? await SessionFilesystem.workspace(opts.sessionID) : Instance.directory) this.environment = { @@ -317,6 +341,7 @@ class PythonKernel implements Kernel { env: { ...OpenScience.kernelEnv(process.env), ...OpenScience.pythonThreadCapEnv(process.env), + ...(sandboxed.env ?? {}), ...(opts?.env ?? {}), ATLAS_CLI_CONFIG_PATH: configPath, MPLCONFIGDIR: path.join(cachePath, "matplotlib"), @@ -613,6 +638,16 @@ export const NotebookTool = Tool.define("notebook", { .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/) .optional() .describe("Stable name for an isolated managed kernel. Use a distinct name for each parallel analysis."), + environment: z + .string() + .trim() + .min(1) + .max(64) + .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/) + .optional() + .describe( + "Managed package environment this kernel runs in. Defaults to the project's default environment. Changing it restarts the kernel.", + ), timeout: z.number().default(120_000).describe("Execution timeout in ms (default: 120s, max: 600s)"), }) .superRefine((params, issue) => { @@ -657,11 +692,20 @@ export const NotebookTool = Tool.define("notebook", { metadata: {}, }) - const result = await KernelRuntime.execute(identity, params.code!, { - timeout: params.timeout, - signal: ctx.abort, - origin: { messageID: ctx.messageID, callID: ctx.callID, title, source: params.source }, - }) + // The binding point is the tool, not a route: POST /kernels was removed in + // #274/#275 and the agent names kernels through this parameter, so + // `environment` belongs beside `kernel`. Resolved to a directory here + // because the registry binds to a path, never to a project-scoped name. + const result = await KernelRuntime.execute( + identity, + params.code!, + { + timeout: params.timeout, + signal: ctx.abort, + origin: { messageID: ctx.messageID, callID: ctx.callID, title, source: params.source }, + }, + { environment: Environment.directory(Instance.project.id, params.environment ?? "default") }, + ) const images = result.outputs.filter((o) => o.type === "display" && o.data?.["image/png"]) const dataUrls = images.map((o) => `data:image/png;base64,${o.data!["image/png"]}`) diff --git a/backend/cli/src/tool/package.ts b/backend/cli/src/tool/package.ts new file mode 100644 index 00000000..e7633525 --- /dev/null +++ b/backend/cli/src/tool/package.ts @@ -0,0 +1,263 @@ +import z from "zod" +import { Environment } from "../package/environment" +import { Installer } from "../package/installer" +import { InstallerR } from "../package/installer-r" +import { Requirement } from "../package/requirement" +import { Instance } from "../project/instance" +import { KernelProcessIdentity } from "../science/kernel/process" +import { KernelRuntime } from "../science/kernel/registry" +import { Tool } from "./tool" + +/** The public index, shown on the card and matched by the permission system. + * Redacted through `Requirement.redact` so a credentialled mirror never puts + * a secret on the card and never fragments a standing grant. */ +const DEFAULT_INDEX = Requirement.redact("https://pypi.org/simple") + +export const PackageTool = Tool.define("package_install", { + description: [ + "Install packages into a managed, named environment that kernels can use.", + "This is the only way to add packages. Shell installers (pip, uv pip, conda, poetry) are refused.", + "An environment is scoped to one language: Python packages go to a python environment, R packages to an R environment.", + "A fully-satisfied request installs nothing — check the environment inventory in your context before calling.", + "Installing restarts kernels bound to that environment only when the change is not purely additive.", + ].join("\n"), + parameters: z.object({ + packages: z + .array(z.string().trim().min(1)) + .min(1) + .describe("Package requirements to install, e.g. ['numpy', 'pandas>=2.2']"), + environment: z + .string() + .trim() + .min(1) + .max(64) + .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/) + .default("default") + .describe("Target environment. Created on first install."), + language: z + .enum(["python", "r"]) + .default("python") + .describe("Environment language. An environment is scoped to one."), + source: z + .boolean() + .default(false) + .describe("Allow source builds. Default is wheels-only, which is faster and more reliable."), + wait: z + .boolean() + .default(true) + .describe( + "Wait for the install to finish and report the versions it landed. Set false only for a long install; you then get no versions back and must not claim it succeeded.", + ), + }), + async execute(params, ctx) { + const project = Instance.project.id + const name = params.environment + const directory = Environment.directory(project, name) + const before = await Environment.read(project, name) + + // Parsed for its names only. Resolution happens after approval — the card + // shows the request, so approving two names must not silently approve the + // closure they pull in. + const language = params.language ?? "python" + // R package names are case-sensitive and `.` is meaningful (`data.table`), + // so the PEP 503 normalisation Requirement.parse applies is wrong for them: + // it would turn data.table into data-table and never match what CRAN + // installed. Python keeps the parser, which is what makes `numpy>=2.4` and + // `pandas[performance]` safe to accept. + const parsed = + language === "r" + ? params.packages.map((p) => ({ name: p.trim(), extras: [], specifier: "", marker: "", url: "" })) + : params.packages.map((p) => Requirement.parse(p)) + + // Already satisfied: skip outright — no card, no install, no restart. + // Nothing privileged happens, so nothing needs approving, and a + // fully-satisfied request is not worth a turn. + // A bare name is satisfied by any installed version. A requirement that + // constrains *which* version — a specifier, a direct URL, or extras that + // may not have been installed — is never assumed satisfied: `six==1.17.0` + // against an installed 1.16.0 is an upgrade, and skipping it would silently + // no-op the request and wrongly report the change as additive. Deciding + // that properly needs PEP 440 comparison; deferring to pip, which already + // implements it and no-ops when it is genuinely satisfied, is both correct + // and cheaper than reimplementing it here. + const constrained = parsed.some((p) => p.specifier || p.url || p.extras.length) + const satisfied = before && !constrained && parsed.every((p) => before.installed[p.name]) + if (satisfied) { + // The same metadata shape as the install branch below, deliberately. + // Two shapes would make every consumer — the UI, the session record, a + // test — handle a union whose arms differ only in which keys exist. + const versions = Object.fromEntries(parsed.map((p) => [p.name, before.installed[p.name]!])) + const listed = Object.entries(versions) + .map(([k, v]) => `${k} ${v}`) + .join(", ") + return { + title: `Already installed · ${name}`, + output: `Nothing to do. ${listed} already present in ${name}.`, + metadata: { + environment: name, + installed: false, + ok: true, + additive: true, + versions, + total: before.total, + }, + } + } + + const pattern = Requirement.pattern({ + packages: params.packages, + environment: name, + index: DEFAULT_INDEX, + }) + + ctx.metadata({ title: `Install · ${name}`, metadata: { environment: name, packages: params.packages } }) + + // The ordinary contract, not modal's. Installing a library must not be + // gated more strictly than running arbitrary code, because it costs + // nothing — hence no digest and no spendFilter entry. The command string is + // readable, and changes whenever the approved action changes, so the prompt + // reappears for free when it should. + await ctx.ask({ + permission: "package_install", + patterns: [pattern], + always: ["install*"], + metadata: { environment: name, packages: params.packages, index: DEFAULT_INDEX }, + }) + + // Dispatch without waiting. The lock is still taken, so a second install + // queues exactly as it would otherwise; what changes is that this turn does + // not hold open for it. The claim is written before returning so a CLI + // restart mid-install can tell "still running" from "died", and the output + // deliberately reports no versions — there are none yet, and inventing them + // is precisely what the contract forbids. + if (params.wait === false) { + const running = Environment.lock(project, name, async () => { + await Environment.claim(project, name, process.pid, KernelProcessIdentity.startToken(process.pid)) + try { + const value = await install() + await Environment.release(project, name) + return value + } catch (error) { + // Recorded, not swallowed. Nothing is awaiting this promise, so a + // discarded rejection meant the agent was told "started installing" + // and could never learn otherwise: no manifest written, claim cleared, + // no trace anywhere. The failure now replaces the claim and surfaces + // in the environment inventory on the next request. + await Environment.fail(project, name, error instanceof Error ? error.message : String(error)) + throw error + } + }) + // Already recorded above; this only stops an unobserved rejection + // surfacing as a process-level warning with no context. + running.catch(() => undefined) + return { + title: `Installing · ${name}`, + output: [ + `Started installing ${params.packages.join(", ")} into ${name}.`, + `It is still running. Do not execute in this environment, and do not report a version, until a later call confirms what landed.`, + ].join("\n"), + metadata: { environment: name, installed: false, ok: true, additive: true, versions: {}, total: 0 }, + } + } + + return await Environment.lock(project, name, install) + + async function install() { + // Only the backend differs by language. The card, the lock, the manifest + // write and the additivity check are identical, because they are + // properties of the contract rather than of pip or CRAN. + const r = language === "r" + if (r) await InstallerR.create(directory) + const tool = r ? undefined : await Installer.probe(directory) + if (tool) await Installer.create(directory, tool) + + // Two different questions, deliberately asked of two different sources. + // `owned` is what the environment itself holds and becomes the manifest. + // `seen` is everything the interpreter can import, inherited packages + // included, and is the only correct basis for the restart decision: + // requesting a version the host already provides installs nothing + // locally, so an owned-set comparison reads the NEXT version as an + // addition and leaves stale modules loaded in live kernels. + const owned = () => (r ? InstallerR.freeze(directory) : Installer.freeze(directory)) + const seen = () => (r ? InstallerR.resolved(directory) : Installer.resolved(directory)) + const snapshot = await seen() + + // Report what pip is doing while it does it. A tool with no dedicated + // renderer otherwise shows its name and an ellipsis for the whole call — + // measured at 1m37s for a pytorch install, with pip reporting phase and + // size the entire time. `metadata` is re-read as the call runs, so this + // reaches the running row; `input` is fixed at call time and cannot. + const progress = (status: string) => + ctx.metadata({ + title: `Install · ${name}`, + metadata: { environment: name, packages: params.packages, progress: status }, + }) + + const result = r + ? await InstallerR.install({ directory, packages: params.packages, signal: ctx.abort }) + : await Installer.install({ + directory, + packages: params.packages, + index: "", + source: params.source, + signal: ctx.abort, + onProgress: progress, + }) + + // Modern pip builds every wheel before the install phase, so a build + // failure aborts before anything is committed — verified during design, + // where a failing package's cleanly-resolving dependency was downloaded + // and still not installed. There is no subset to keep and nothing to + // retry, so this reports the cause and stops. R is checked explicitly by + // InstallerR, because install.packages() only warns and still exits 0. + if (!result.ok) throw new Error(r ? InstallerR.explain(result.log) : Installer.explain(result.log)) + + const after = await seen() + const held = await owned() + const names = parsed.map((p) => p.name) + const versions = r ? await InstallerR.verify(directory, names) : await Installer.verify(directory, names) + + const requested = Array.from(new Set([...(before?.requested ?? []), ...parsed.map((p) => p.name)])) + await Environment.write(project, { + name, + // Defaulted here rather than relied on from the schema: `execute` is + // reachable without zod having applied parameter defaults, and an + // undefined language used to produce a manifest that could never be + // read back. + language, + requested, + installed: held, + total: Object.keys(held).length, + createdAt: before?.createdAt ?? Date.now(), + updatedAt: Date.now(), + }) + + // Kernels bind to the environment *directory*, so that is what identifies + // them here — not the name, which the registry never sees. + const additive = Environment.additive(snapshot, after) + if (!additive) await KernelRuntime.restartEnvironment(project, directory) + + const landed = Object.entries(versions) + .map(([k, v]) => `${k} ${v}`) + .join(", ") + return { + title: `Installed · ${name}`, + output: [ + `Installed into ${name}: ${landed || "(nothing reported)"}.`, + `${Object.keys(held).length} packages total in the environment.`, + additive + ? "Purely additive — running kernels kept their state." + : "Not purely additive — kernels bound to this environment restarted and their variables were discarded.", + ].join("\n"), + metadata: { + environment: name, + installed: true, + ok: true, + additive, + versions, + total: Object.keys(held).length, + }, + } + } + }, +}) diff --git a/backend/cli/src/tool/registry.ts b/backend/cli/src/tool/registry.ts index 4f71c103..3c0b8198 100644 --- a/backend/cli/src/tool/registry.ts +++ b/backend/cli/src/tool/registry.ts @@ -35,6 +35,7 @@ import { ScienceTools } from "./science" import { ProvenanceTools } from "./provenance" import { NotebookTool } from "./notebook" import { RKernelTool } from "./rkernel" +import { PackageTool } from "./package" import { AtlasTool } from "./atlas" import { AtlasRecordTool } from "./atlas-record" import { ArtifactSnapshotTool } from "./artifact-snapshot" @@ -139,6 +140,7 @@ export namespace ToolRegistry { AtlasRecordTool, NotebookTool, RKernelTool, + PackageTool, ArtifactTool, LearnTool, ModalTool, diff --git a/backend/cli/src/tool/rkernel.ts b/backend/cli/src/tool/rkernel.ts index bf845640..3e120db0 100644 --- a/backend/cli/src/tool/rkernel.ts +++ b/backend/cli/src/tool/rkernel.ts @@ -9,7 +9,10 @@ import { Instance } from "@/project/instance" import { OpenScience } from "@/openscience" import { Config } from "@/config/config" import { SessionFilesystem } from "@/session/filesystem" +import { Environment } from "@/package/environment" +import { Installer } from "@/package/installer" import { Sandbox } from "@/sandbox/sandbox" +import { EgressRuntime } from "@/sandbox/egress-runtime" import { KernelQueue } from "@/science/kernel/queue" import { KernelProcessIdentity } from "@/science/kernel/process" import { KernelRuntime } from "@/science/kernel/registry" @@ -258,13 +261,18 @@ class RKernel implements Kernel { // kernel runs arbitrary agent-authored code — the same threat model as the // bash tool — so it must respect the same boundary. const policy = await Config.trustedSandbox() + const egress = await EgressRuntime.egressFor(policy) const sandboxed = Sandbox.wrapArgv({ file: bin, args: ["--vanilla", scriptPath], workspace, extraWritable: [scriptPath, configPath], + // The R library the kernel reads packages from. Read-only for the same + // reason the Python interpreter is: a writable library would let cell + // code call install.packages() directly and bypass the approval card. + ...(opts?.environment ? { readable: [Installer.rlibrary(opts.environment)] } : {}), unreadable: OpenScience.kernelSensitivePaths(), - options: policy, + options: { ...policy, egress }, }) const cwd = opts?.cwd ?? (opts?.sessionID ? await SessionFilesystem.workspace(opts.sessionID) : Instance.directory) this.environment = { @@ -283,6 +291,12 @@ class RKernel implements Kernel { cwd, env: { ...OpenScience.kernelEnv(process.env), + ...(sandboxed.env ?? {}), + // R has no per-environment binary to point at, so the binding is a + // library path. R_LIBS_USER is already in the kernel env allowlist, and + // `install.packages` always exists — which is why R needs no installer + // ladder the way Python does. + ...(opts?.environment ? { R_LIBS_USER: Installer.rlibrary(opts.environment) } : {}), ...(opts?.env ?? {}), ATLAS_CLI_CONFIG_PATH: configPath, }, @@ -554,6 +568,16 @@ export const RKernelTool = Tool.define("rkernel", { .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/) .optional() .describe("Stable name for an isolated managed kernel. Use a distinct name for each parallel analysis."), + environment: z + .string() + .trim() + .min(1) + .max(64) + .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/) + .optional() + .describe( + "Managed package environment this kernel runs in. Defaults to the project's default environment. Changing it restarts the kernel.", + ), timeout: z.number().default(120_000).describe("Execution timeout in ms (default: 120s, max: 600s)"), }) .superRefine((params, issue) => { @@ -612,11 +636,16 @@ export const RKernelTool = Tool.define("rkernel", { } } - const result = await KernelRuntime.execute(identity, params.code!, { - timeout: params.timeout, - signal: ctx.abort, - origin: { messageID: ctx.messageID, callID: ctx.callID, title, source: params.source }, - }) + const result = await KernelRuntime.execute( + identity, + params.code!, + { + timeout: params.timeout, + signal: ctx.abort, + origin: { messageID: ctx.messageID, callID: ctx.callID, title, source: params.source }, + }, + { environment: Environment.directory(Instance.project.id, params.environment ?? "default") }, + ) const images = result.outputs.filter((o) => o.type === "display" && o.data?.["image/png"]) const dataUrls = images.map((o) => `data:image/png;base64,${o.data!["image/png"]}`) diff --git a/backend/cli/test/compute/jobs.test.ts b/backend/cli/test/compute/jobs.test.ts index 59539a3c..478d3868 100644 --- a/backend/cli/test/compute/jobs.test.ts +++ b/backend/cli/test/compute/jobs.test.ts @@ -1634,3 +1634,35 @@ describe("ComputeJobs project boundaries", () => { } }) }) + +describe("ComputeJobs ssh transport network policy", () => { + test("allowlist is relaxed, because the allowlist proxy is HTTP-only", () => { + // The bug: under "allowlist" the ssh CLIENT was wrapped in a severed + // network namespace whose only exit is an HTTP proxy socket. ssh does not + // read HTTP_PROXY and cannot use it, so remote jobs failed with an opaque + // connection error on the shipped default policy. + expect(ComputeJobs.transportNetwork("allowlist")).toBe("allow") + }) + + test("an explicit deny is honoured, not overridden", () => { + // The line between relaxing a default nobody chose and overriding an + // instruction somebody gave. A user who set "deny" means it. + expect(ComputeJobs.transportNetwork("deny")).toBe("deny") + }) + + test("allow is unchanged", () => { + expect(ComputeJobs.transportNetwork("allow")).toBe("allow") + }) + + test("the relaxation is reported, never silent", async () => { + // Reporting "allowlist" for a process actually running unconfined would be + // worse than the original bug, so the launch path reports the policy it + // applied and says why. + const source = await Bun.file(new URL("../../src/compute/jobs.ts", import.meta.url).pathname).text() + expect(source.includes("network left unconfined for the ssh transport")).toBe(true) + // The ssh branch reports the policy it APPLIED. The local-job branch below + // it still reports authority.sandbox.network, and correctly so — there the + // requested policy is the applied one, and nothing is relaxed. + expect(source.includes("const network = transportNetwork(authority.sandbox.network)")).toBe(true) + }) +}) diff --git a/backend/cli/test/installation/native-package-matrix.test.ts b/backend/cli/test/installation/native-package-matrix.test.ts index b74a2d96..5453ca26 100644 --- a/backend/cli/test/installation/native-package-matrix.test.ts +++ b/backend/cli/test/installation/native-package-matrix.test.ts @@ -19,8 +19,17 @@ async function pack(dir: string, output: string) { proc.exited, ]) if (code !== 0) throw new Error(stderr || stdout) - const result = JSON.parse(stdout) as { filename?: string }[] - const file = result[0]?.filename + // `npm pack --json` changed shape: npm 11 and earlier return an array of + // entries, npm 12 returns an object keyed by package name. Indexing [0] + // yields undefined on npm 12, so this failed with "did not return a tarball" + // on any machine with a current npm while still passing on CI's older one. + // Accept both rather than pinning a version — the test is about npm's + // package *selection*, not about its output format. + const parsed = JSON.parse(stdout) as unknown + const entries = (Array.isArray(parsed) ? parsed : Object.values(parsed as Record)) as { + filename?: string + }[] + const file = entries[0]?.filename if (!file) throw new Error(`npm pack did not return a tarball for ${dir}`) return path.join(output, file) } diff --git a/backend/cli/test/package/binding.test.ts b/backend/cli/test/package/binding.test.ts new file mode 100644 index 00000000..c9abd003 --- /dev/null +++ b/backend/cli/test/package/binding.test.ts @@ -0,0 +1,202 @@ +import { expect, test } from "bun:test" +import fs from "fs" +import path from "path" +import { Environment } from "../../src/package/environment" +import { Installer } from "../../src/package/installer" +import { findPython } from "../../src/tool/notebook" +import { tmpdir } from "../fixture/fixture" + +const python = Bun.which("python3") + +const read = (relative: string) => Bun.file(new URL(relative, import.meta.url).pathname).text() + +test.skipIf(!python)("a kernel bound to an environment runs that environment's interpreter", async () => { + await using dir = await tmpdir() + const env = path.join(dir.path, "env") + await Installer.create(env, { kind: "venv", binary: python! }) + expect(fs.existsSync(Installer.interpreter(env))).toBe(true) + expect(await findPython(undefined, env)).toBe(Installer.interpreter(env)) +}) + +test("an environment that does not exist falls back to the host interpreter", async () => { + // Failing closed here would make a typo'd environment name look like a + // broken machine — the exact failure mode this whole design started from, + // where a missing pip, a severed network and a read-only site-packages all + // surfaced as one opaque error. + const resolved = await findPython(undefined, "/nonexistent/env") + expect(resolved).not.toContain("/nonexistent/env") +}) + +test("no environment at all is the unchanged host lookup", async () => { + expect(await findPython()).toBeString() +}) + +test("environment binding is not part of the kernel identity", async () => { + const source = await read("../../src/science/kernel/registry.ts") + const identity = source.slice(source.indexOf("export type KernelIdentity"), source.indexOf("type KernelCell")) + // Adding it to the tuple would rekey every persisted record and orphan them. + expect(identity.includes("environment")).toBe(false) +}) + +test("the binding is carried under a name that cannot collide with KernelEnvironment", async () => { + const source = await read("../../src/science/kernel/registry.ts") + // The entry already has an `environment` field of type KernelEnvironment — + // the kernel's runtime context (cwd, sandbox platform), nothing to do with + // packages. Merging the two would silently bind kernels to the wrong thing. + expect(source.includes("boundEnvironment")).toBe(true) +}) + +test("changing the bound environment restarts the kernel rather than reusing it", async () => { + const source = await read("../../src/science/kernel/registry.ts") + // Staleness is compared at the registry level, because ExecutionAuthority's + // signature carries no kernel identity and so cannot see this. + expect(source.includes("value.boundEnvironment !== ")).toBe(true) +}) + +test("the notebook tool exposes environment beside kernel", async () => { + const source = await read("../../src/tool/notebook.ts") + expect(source.includes("environment: z")).toBe(true) +}) + +test("the R kernel tool exposes environment too", async () => { + const source = await read("../../src/tool/rkernel.ts") + expect(source.includes("environment: z")).toBe(true) +}) + +test("the derived directory is stable for a project and name", () => { + expect(Environment.directory("p", "e")).toBe(Environment.directory("p", "e")) +}) + +// Everything above is structural. This runs a real kernel and asks it what it +// can import — the only assertion that can distinguish "the parameter is +// plumbed" from "the kernel actually runs in that environment". + +async function context() { + const { executionSession } = await import("../fixture/fixture") + const session = await executionSession() + return { + sessionID: session.id, + messageID: "", + callID: "", + agent: "research", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, + } +} + +const live = (await import("../../src/sandbox/sandbox")).Sandbox.backend() !== "none" && Boolean(python) + +test.skipIf(!live)( + "a package installed into one environment is importable there and absent elsewhere", + async () => { + const { Instance } = await import("../../src/project/instance") + const { NotebookTool } = await import("../../src/tool/notebook") + const { PackageTool } = await import("../../src/tool/package") + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const install = await PackageTool.init() + await install.execute( + { packages: ["tqdm"], environment: "bound", language: "python", source: false, wait: true }, + await context(), + ) + + const notebook = await NotebookTool.init() + const inBound = await notebook.execute( + { + code: "import tqdm; print('BOUND', tqdm.__version__)", + kernel: "k-bound", + environment: "bound", + timeout: 120_000, + }, + await context(), + ) + expect(inBound.metadata.output).toContain("BOUND") + + // A second REAL environment, created by installing something else into + // it. Naming an environment that does not exist would not prove + // isolation: findPython deliberately falls back to the host + // interpreter, and the host may well have tqdm — measured, it does. + await install.execute( + { packages: ["six"], environment: "other", language: "python", source: false, wait: true }, + await context(), + ) + const elsewhere = await notebook.execute( + { + code: [ + "import importlib.util as u", + "print('TQDM', 'FOUND' if u.find_spec('tqdm') else 'ABSENT')", + "print('SIX', 'FOUND' if u.find_spec('six') else 'ABSENT')", + ].join("\n"), + kernel: "k-other", + environment: "other", + timeout: 120_000, + }, + await context(), + ) + // If binding were cosmetic both kernels would see the same site-packages + // and this would report TQDM FOUND — the exact false green a + // plumbing-only test cannot rule out. + expect(elsewhere.metadata.output).toContain("TQDM ABSENT") + expect(elsewhere.metadata.output).toContain("SIX FOUND") + }, + }) + }, + 600_000, +) + +test.skipIf(!live)( + "an additive install keeps kernel state, a version change discards it", + async () => { + const { Instance } = await import("../../src/project/instance") + const { NotebookTool } = await import("../../src/tool/notebook") + const { PackageTool } = await import("../../src/tool/package") + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const install = await PackageTool.init() + const notebook = await NotebookTool.init() + // ONE context for every cell. A fresh session id is a different + // KernelIdentity and therefore a different kernel, so re-deriving it + // per cell would look exactly like a restart and make this test pass + // for the wrong reason — measured: `marker` was undefined between two + // consecutive cells with no install between them. + const shared = await context() + const cell = async (code: string) => + (await notebook.execute({ code, kernel: "k-restart", environment: "restart", timeout: 120_000 }, shared)) + .metadata.output + + await install.execute( + { packages: ["six==1.16.0"], environment: "restart", language: "python", source: false, wait: true }, + shared, + ) + await cell("marker = 'alive'") + expect(await cell("print(marker)")).toContain("alive") + + // Additive: a package that was not there before. A live kernel stays + // correct, because a new module imports on first use. + const additive = await install.execute( + { packages: ["tqdm"], environment: "restart", language: "python", source: false, wait: true }, + shared, + ) + expect(additive.metadata.additive).toBe(true) + expect(await cell("print(marker)")).toContain("alive") + + // Not additive: six changes version. The module already loaded into the + // interpreter would stay at 1.16.0 in memory while the files on disk say + // 1.17.0 — silently wrong, which is why this restarts. + const changed = await install.execute( + { packages: ["six==1.17.0"], environment: "restart", language: "python", source: false, wait: true }, + shared, + ) + expect(changed.metadata.additive).toBe(false) + expect(await cell("print(marker)")).toContain("NameError") + }, + }) + }, + 900_000, +) diff --git a/backend/cli/test/package/dispatch.test.ts b/backend/cli/test/package/dispatch.test.ts new file mode 100644 index 00000000..17bb8d29 --- /dev/null +++ b/backend/cli/test/package/dispatch.test.ts @@ -0,0 +1,274 @@ +import { expect, test } from "bun:test" +import { Environment } from "../../src/package/environment" +import { KernelProcessIdentity } from "../../src/science/kernel/process" + +const seed = async (project: string, name: string) => + Environment.write(project, { + name, + language: "python", + requested: [], + installed: {}, + total: 0, + createdAt: 1, + updatedAt: 1, + }) + +test("a claim by a live process reconciles as still running", async () => { + const project = "proj_reconcile_live" + await seed(project, "live") + // This process is by definition alive, so it stands in for a live installer. + await Environment.claim(project, "live", process.pid, KernelProcessIdentity.startToken(process.pid)) + const outcomes = await Environment.reconcile(project) + expect(outcomes.find((o) => o.name === "live")?.outcome).toBe("running") +}) + +test("a live pid with no token available still reconciles as running", async () => { + // Windows has neither the /proc nor the `ps -o lstart=` branch, so the token + // is undefined there for every process. Treating that as unproven would mark + // every Windows install unknown forever and every environment permanently + // suspect. `matches()` already takes liveness alone as sufficient in that + // case; reconcile follows the same rule. + const project = "proj_reconcile_untokened" + await seed(project, "untokened") + await Environment.claim(project, "untokened", process.pid, undefined) + const outcomes = await Environment.reconcile(project) + expect(outcomes.find((o) => o.name === "untokened")?.outcome).toBe("running") +}) + +test("a claim by a dead pid reconciles as unknown, not as success", async () => { + const project = "proj_reconcile_dead" + await seed(project, "dead") + // process.execPath, not /bin/true: that path does not exist on macOS (it is + // /usr/bin/true there), and posix_spawn's ENOENT surfaced as this test + // failing for a reason unrelated to reconcile. Bun is by definition present. + const proc = Bun.spawn([process.execPath, "-e", ""], { stdout: "ignore", stderr: "ignore" }) + const pid = proc.pid + const captured = KernelProcessIdentity.startToken(pid) + await proc.exited + // `await proc.exited` is not the same as "the pid is gone": a just-reaped + // child can stay signalable briefly, and on a macOS runner it did — the + // claim then reconciled as "running" and this failed for a reason that had + // nothing to do with reconcile. Wait for the premise to actually hold, and + // fail loudly if it never does rather than asserting on a live pid. + for (let i = 0; i < 100; i++) { + try { + process.kill(pid, 0) + } catch { + break + } + await Bun.sleep(20) + } + expect(() => process.kill(pid, 0)).toThrow() + await Environment.claim(project, "dead", pid, captured) + const outcomes = await Environment.reconcile(project) + // Not "fine": pip has no transactions, so an interrupted install may have + // written a partial tree. Silently trusting it is how a half-installed + // environment becomes a mystery ImportError three turns later. + expect(outcomes.find((o) => o.name === "dead")?.outcome).toBe("unknown") +}) + +test("a claim whose token no longer matches reconciles as unknown", async () => { + // pid reuse: the number is alive but it is a different process. + const project = "proj_reconcile_reused" + await seed(project, "reused") + await Environment.claim(project, "reused", process.pid, "not-the-real-token") + const outcomes = await Environment.reconcile(project) + expect(outcomes.find((o) => o.name === "reused")?.outcome).toBe("unknown") +}) + +test("a corrupt claim file reconciles as unknown rather than throwing", async () => { + const project = "proj_reconcile_corrupt" + await seed(project, "corrupt") + await Environment.claim(project, "corrupt", process.pid, undefined) + await Bun.write(Environment.claimPath(project, "corrupt"), "{not json") + const outcomes = await Environment.reconcile(project) + expect(outcomes.find((o) => o.name === "corrupt")?.outcome).toBe("unknown") +}) + +test("reconcile clears a resolved claim so it is not reported twice", async () => { + const project = "proj_reconcile_once" + await seed(project, "once") + await Environment.claim(project, "once", 999_999, "gone") + expect(await Environment.reconcile(project)).toHaveLength(1) + expect(await Environment.reconcile(project)).toHaveLength(0) +}) + +test("reconcile keeps a claim that is still running, so a later check still sees it", async () => { + const project = "proj_reconcile_keep" + await seed(project, "keep") + await Environment.claim(project, "keep", process.pid, KernelProcessIdentity.startToken(process.pid)) + expect(await Environment.reconcile(project)).toHaveLength(1) + expect(await Environment.reconcile(project)).toHaveLength(1) +}) + +test("a project with no claims reconciles to nothing", async () => { + expect(await Environment.reconcile("proj_no_claims_at_all")).toEqual([]) +}) + +const python = Bun.which("python3") +const live = (await import("../../src/sandbox/sandbox")).Sandbox.backend() !== "none" && Boolean(python) + +test.skipIf(!live)( + "wait:false returns before the install finishes, then the package really lands", + async () => { + const { Instance } = await import("../../src/project/instance") + const { PackageTool } = await import("../../src/tool/package") + const { Installer } = await import("../../src/package/installer") + const { executionSession, tmpdir } = await import("../fixture/fixture") + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await executionSession() + const ctx = { + sessionID: session.id, + messageID: "", + callID: "", + agent: "research", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, + } + const tool = await PackageTool.init() + const result = await tool.execute( + { packages: ["tqdm"], environment: "async", language: "python" as const, source: false, wait: false }, + ctx as never, + ) + + // No versions, and it does not claim success — there is nothing to + // report yet, and inventing a version is what the contract forbids. + expect(result.metadata.installed).toBe(false) + expect(result.metadata.versions).toEqual({}) + expect(result.output).toContain("still running") + + // The work really is in flight: taking the lock waits it out, and the + // package is present afterwards. + const directory = Environment.directory(Instance.project.id, "async") + await Environment.lock(Instance.project.id, "async", async () => {}) + expect((await Installer.verify(directory, ["tqdm"]))["tqdm"]).toMatch(/^\d/) + + // And the claim is cleared once it finishes, so a later reconcile does + // not report a phantom install. + expect(await Environment.reconcile(Instance.project.id)).toEqual([]) + }, + }) + }, + 600_000, +) + +test("a claim survives a hard kill of its process and reconciles as unknown", async () => { + // The scenario the claim/token machinery exists for, which nothing exercised: + // the CLI is killed while an install runs, and on restart a claim file points + // at a pid that is gone. Every other test here uses a process that exited + // normally, or a synthetic pid. This one kills a live process outright and + // watches the SAME claim flip from running to unknown. + const project = "proj_reconcile_killed" + await seed(project, "killed") + + const proc = Bun.spawn([process.execPath, "-e", "setTimeout(() => {}, 60_000)"], { + stdout: "ignore", + stderr: "ignore", + }) + const pid = proc.pid + await Environment.claim(project, "killed", pid, KernelProcessIdentity.startToken(pid)) + + // Alive: the claim is true right now, so reconcile must leave it alone. + const before = await Environment.reconcile(project) + expect(before.find((o) => o.name === "killed")?.outcome).toBe("running") + + proc.kill("SIGKILL") + await proc.exited + for (let i = 0; i < 100; i++) { + try { + process.kill(pid, 0) + } catch { + break + } + await Bun.sleep(20) + } + expect(() => process.kill(pid, 0)).toThrow() + + // Dead: pip has no transactions, so an interrupted install may have left a + // partial tree. "unknown" is the only honest answer; "fine" would turn into a + // mystery ImportError several turns later. + const after = await Environment.reconcile(project) + expect(after.find((o) => o.name === "killed")?.outcome).toBe("unknown") + // And it is cleared, so a later boot does not re-report a resolved claim. + expect(await Environment.reconcile(project)).toEqual([]) +}, 60_000) + +test("a real install can be interrupted, and the environment is still usable after", async () => { + // The other half: after an abort, the environment must not be wedged. A + // half-written tree that no later install can repair would be worse than the + // interruption itself. + const { Sandbox } = await import("../../src/sandbox/sandbox") + const python = Bun.which("python3") + if (Sandbox.backend() === "none" || !python) return + const { Installer } = await import("../../src/package/installer") + const { tmpdir } = await import("../fixture/fixture") + + await using dir = await tmpdir() + const env = (await import("path")).join(dir.path, "env") + await Installer.create(env, await Installer.probe(env)) + + const control = new AbortController() + const running = Installer.install({ + directory: env, + packages: ["scipy"], + index: "", + source: false, + signal: control.signal, + }) + await Bun.sleep(600) + control.abort() + await running.catch(() => undefined) + + // The environment survives: a subsequent install into it works. + const after = await Installer.install({ directory: env, packages: ["tqdm"], index: "", source: false }) + expect(after.ok, after.log).toBe(true) + expect((await Installer.verify(env, ["tqdm"]))["tqdm"]).toMatch(/^\d/) +}, 600_000) + +test("a failed detached install is recorded, not swallowed", async () => { + // `wait: false` returns immediately and nothing awaits the promise, so a + // rejection used to be discarded outright: no manifest written, the claim + // released cleanly, no trace anywhere. The agent had been told "started + // installing" and could never learn otherwise. + const project = "proj_failed_detached" + await seed(project, "broken") + await Environment.fail(project, "broken", "No wheel is published for xyzzy under the current policy.") + const outcomes = await Environment.reconcile(project) + const found = outcomes.find((o) => o.name === "broken") + expect(found?.outcome).toBe("failed") + expect(found?.message).toContain("No wheel") + // Reported once, then cleared — a failure that repeated every request would + // be worse than one that vanished. + expect(await Environment.reconcile(project)).toEqual([]) +}) + +test("an unresolved install reaches the agent's contract, not just a log", async () => { + // reconcile() had no production caller at all: built, tested, and reached + // only by its own tests. It now runs where the result can act — the + // capability block injected on every request. + const { PackagePrompt } = await import("../../src/package/prompt") + const project = "proj_warning_surfaces" + await seed(project, "halfdone") + await Environment.claim(project, "halfdone", 999_998, "definitely-gone") + const block = await PackagePrompt.system(project) + expect(block).toContain("UNRESOLVED INSTALLS") + expect(block).toContain("halfdone") + expect(block).toContain("outcome is unknown") + // Self-clearing: the next request is clean. + expect(await PackagePrompt.system(project)).not.toContain("UNRESOLVED INSTALLS") +}) + +test("a recorded failure is reported to the agent with its cause", async () => { + const { PackagePrompt } = await import("../../src/package/prompt") + const project = "proj_failure_surfaces" + await seed(project, "nowheel") + await Environment.fail(project, "nowheel", "No wheel is published for xyzzy.") + const block = await PackagePrompt.system(project) + expect(block).toContain("FAILED and nothing was landed") + expect(block).toContain("No wheel is published") +}) diff --git a/backend/cli/test/package/environment.test.ts b/backend/cli/test/package/environment.test.ts new file mode 100644 index 00000000..567ba3d2 --- /dev/null +++ b/backend/cli/test/package/environment.test.ts @@ -0,0 +1,181 @@ +import { expect, test } from "bun:test" +import path from "path" +import { Global } from "../../src/global" +import { Environment } from "../../src/package/environment" + +const project = "proj_test" + +test("the manifest lives under data and the directory under cache", () => { + // Not interchangeable: the manifest is the source of truth and the directory + // is derived, so a cache cleaner must be able to remove one without + // destroying the record of what the environment is. + expect(Environment.manifest(project, "default")).toBe(path.join(Global.Path.data, "envs", project, "default.json")) + expect(Environment.directory(project, "default")).toBe(path.join(Global.Path.cache, "envs", project, "default")) +}) + +test("a written environment reads back", async () => { + const value = { + name: "e1", + language: "python" as const, + requested: ["numpy"], + installed: { numpy: "2.1.0" }, + total: 1, + createdAt: 1, + updatedAt: 1, + } + await Environment.write(project, value) + expect(await Environment.read(project, "e1")).toEqual(value) +}) + +test("writing a manifest that could not be read back throws instead", async () => { + // Regression. JSON.stringify drops undefined-valued keys, so a caller that + // omits one — a tool invoked without zod having applied its defaults — wrote + // a manifest that read() then rejected. The environment existed on disk, + // held installed packages, and was invisible to the inventory: silent, and + // indistinguishable from "never created" at every call site. + const bad = { name: "hole", requested: [], installed: {}, total: 0, createdAt: 1, updatedAt: 1 } + await expect(Environment.write(project, bad as never)).rejects.toThrow("unreadable") + expect(await Environment.read(project, "hole")).toBeUndefined() +}) + +test("a manifest that round-trips is exactly what write validated", async () => { + const value = { + name: "roundtrip", + language: "python" as const, + requested: ["numpy"], + installed: { numpy: "2.1.0" }, + total: 1, + createdAt: 1, + updatedAt: 2, + } + await Environment.write(project, value) + expect(await Environment.read(project, "roundtrip")).toEqual(value) +}) + +test("reading an environment that does not exist is undefined, not a throw", async () => { + expect(await Environment.read(project, "never-created")).toBeUndefined() +}) + +test("list returns every environment for the project and none from another", async () => { + await Environment.write(project, { + name: "e2", + language: "python", + requested: [], + installed: {}, + total: 0, + createdAt: 1, + updatedAt: 1, + }) + await Environment.write("other_project", { + name: "e3", + language: "python", + requested: [], + installed: {}, + total: 0, + createdAt: 1, + updatedAt: 1, + }) + const names = (await Environment.list(project)).map((e) => e.name) + expect(names).toContain("e2") + expect(names).not.toContain("e3") +}) + +test("a corrupt manifest is skipped, not fatal to the whole listing", async () => { + // One hand-edited or half-written file must not make every environment in + // the project invisible. + const fs = await import("fs/promises") + const file = Environment.manifest(project, "corrupt") + await fs.mkdir(path.dirname(file), { recursive: true }) + await Bun.write(file, "{not json") + const names = (await Environment.list(project)).map((e) => e.name) + expect(names).not.toContain("corrupt") + expect(names).toContain("e2") +}) + +// The additivity rule decides whether kernels restart, so each direction is +// asserted separately rather than as one truthiness check. +test("adding a package is additive", () => { + expect(Environment.additive({ numpy: "2.1.0" }, { numpy: "2.1.0", pandas: "2.2.0" })).toBe(true) +}) + +test("an unchanged set is additive", () => { + expect(Environment.additive({ numpy: "2.1.0" }, { numpy: "2.1.0" })).toBe(true) +}) + +test("an upgrade is NOT additive — a loaded module would stay stale", () => { + expect(Environment.additive({ numpy: "2.1.0" }, { numpy: "2.2.0" })).toBe(false) +}) + +test("a downgrade is NOT additive", () => { + expect(Environment.additive({ numpy: "2.2.0" }, { numpy: "2.1.0" })).toBe(false) +}) + +test("a removal is NOT additive", () => { + expect(Environment.additive({ numpy: "2.1.0", pandas: "2.2.0" }, { numpy: "2.1.0" })).toBe(false) +}) + +test("the lock serialises two installs into the same environment", async () => { + const order: string[] = [] + const first = Environment.lock(project, "locked", async () => { + order.push("first-start") + await Bun.sleep(50) + order.push("first-end") + }) + const second = Environment.lock(project, "locked", async () => { + order.push("second-start") + }) + await Promise.all([first, second]) + // Not interleaved: a cell that lazily imports a submodule mid-install can + // load a half-written file, so this is correctness, not scheduling. + expect(order).toEqual(["first-start", "first-end", "second-start"]) +}) + +test("a different environment is not blocked by a held lock", async () => { + const order: string[] = [] + const held = Environment.lock(project, "envA", async () => { + await Bun.sleep(80) + order.push("A") + }) + const free = Environment.lock(project, "envB", async () => { + order.push("B") + }) + await Promise.all([held, free]) + expect(order).toEqual(["B", "A"]) +}) + +test("busy() reports the lock while it is held and clears after", async () => { + let seen = false + await Environment.lock(project, "watched", async () => { + seen = Environment.busy(project, "watched") + }) + expect(seen).toBe(true) + expect(Environment.busy(project, "watched")).toBe(false) +}) + +test("the lock releases even when the body throws", async () => { + await Environment.lock(project, "boom", async () => { + throw new Error("install failed") + }).catch(() => {}) + // Otherwise one failed install bricks that environment for the process + // lifetime — the same latching bug the egress runtime shipped with. + expect(Environment.busy(project, "boom")).toBe(false) +}) + +test("a throw in the first holder does not cancel the one queued behind it", async () => { + const order: string[] = [] + const failing = Environment.lock(project, "chain", async () => { + order.push("first") + throw new Error("boom") + }) + const queued = Environment.lock(project, "chain", async () => { + order.push("second") + return "done" + }) + await failing.catch(() => {}) + expect(await queued).toBe("done") + expect(order).toEqual(["first", "second"]) +}) + +test("the lock returns the body's value to its own caller", async () => { + expect(await Environment.lock(project, "value", async () => 42)).toBe(42) +}) diff --git a/backend/cli/test/package/install-live.test.ts b/backend/cli/test/package/install-live.test.ts new file mode 100644 index 00000000..e346ae13 --- /dev/null +++ b/backend/cli/test/package/install-live.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from "bun:test" +import { Environment } from "../../src/package/environment" +import { Installer } from "../../src/package/installer" +import { Refuse } from "../../src/package/refuse" +import { Instance } from "../../src/project/instance" +import { Sandbox } from "../../src/sandbox/sandbox" +import { PackageTool } from "../../src/tool/package" +import type { PermissionNext } from "../../src/permission/next" +import { executionSession, tmpdir } from "../fixture/fixture" + +/** + * The merge gate, stated as an assertion. + * + * The condition this branch has to meet is that a package installs **with the + * user's approval**, under `network: "allowlist"`, on every platform we ship. + * Everything else in `test/package/` tests a component; this tests the claim. + * + * Both halves are asserted together on purpose. A green install with an open + * shell bypass is not the gate met — an agent that never calls the tool never + * shows a card, so the refusal is part of the same claim, not an adjacent + * feature. + * + * Gated on a real sandbox backend and a real interpreter, and skips rather than + * fails without them: a green run on a machine with no sandbox would assert + * nothing. On Linux and macOS CI both are present, so it runs unskipped there. + */ + +const python = Bun.which("python3") +const skip = Sandbox.backend() === "none" || !python + +async function approving() { + const session = await executionSession() + const asks: Array> = [] + return { + asks, + ctx: { + sessionID: session.id, + messageID: "", + callID: "", + agent: "research", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async (req: Omit) => { + asks.push(req) + }, + }, + } +} + +describe.skipIf(skip)("merge gate: a governed install under network allowlist", () => { + test("the agent's only install route asks for approval and lands the package", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tool = await PackageTool.init() + const { asks, ctx } = await approving() + const result = await tool.execute( + { packages: ["tqdm"], environment: "gate", language: "python", source: false, wait: true }, + ctx, + ) + + // Approval happened, and it named exactly what ran. + expect(asks).toHaveLength(1) + expect(asks[0]!.permission).toBe("package_install") + expect(asks[0]!.patterns).toEqual(["install tqdm → gate [pypi.org/simple]"]) + + // The package really landed — read back out of the environment, not + // taken from pip's exit code. + expect(result.metadata.versions["tqdm"]).toMatch(/^\d/) + const directory = Environment.directory(Instance.project.id, "gate") + expect((await Installer.verify(directory, ["tqdm"]))["tqdm"]).toMatch(/^\d/) + + // And it happened under the allowlist policy, not with the sandbox off. + const policy = await (await import("../../src/config/config")).Config.trustedSandbox() + expect(policy.network ?? "allowlist").toBe("allowlist") + }, + }) + }, 600_000) + + test("the shell route to the same install is refused", () => { + // The exact line measured to succeed on feat/sandbox-network-policy before + // any of this existed: a venv in the writable workspace, pypi allowlisted, + // no tool and no card. If this ever returns undefined the gate is not met + // even when the test above is green. + expect(Refuse.installer(["/w/venv/bin/pip", "install", "tqdm"])).toBeString() + expect(Refuse.installer(["pip", "install", "tqdm"])).toBeString() + expect(Refuse.installer(["uv", "pip", "install", "tqdm"])).toBeString() + expect(Refuse.installer(["python3", "-m", "pip", "install", "tqdm"])).toBeString() + }) + + test("a package with no wheel under the default policy fails with a translated message", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const directory = Environment.directory(Instance.project.id, "nowheel") + await Installer.create(directory, await Installer.probe(directory)) + const result = await Installer.install({ + directory, + packages: ["this-package-does-not-exist-anywhere-xyzzy"], + index: "", + source: false, + }) + expect(result.ok).toBe(false) + // The raw log reads as "no such package" regardless of which of the + // two things actually happened, which is why explain() exists. + expect(Installer.explain(result.log).length).toBeGreaterThan(0) + }, + }) + }, 600_000) +}) diff --git a/backend/cli/test/package/installer-r.test.ts b/backend/cli/test/package/installer-r.test.ts new file mode 100644 index 00000000..3a454418 --- /dev/null +++ b/backend/cli/test/package/installer-r.test.ts @@ -0,0 +1,136 @@ +import { expect, test } from "bun:test" +import fs from "fs" +import path from "path" +import { Installer } from "../../src/package/installer" +import { InstallerR } from "../../src/package/installer-r" +import { tmpdir } from "../fixture/fixture" + +const rscript = Bun.which("Rscript") +const read = (relative: string) => Bun.file(new URL(relative, import.meta.url).pathname).text() + +test("the library path is derived from the environment directory, beside the interpreter", () => { + // Both language backends derive their binding from one place, so a kernel can + // resolve it before any install has ever run. + expect(Installer.rlibrary("/envs/e")).toBe(path.join("/envs/e", "rlibs")) +}) + +test("the index is CRAN, asserted by value rather than by grepping for a domain", () => { + // Equality on an exported constant, not `source.includes("cran...")`. The + // substring form reads to CodeQL as incomplete URL sanitization — a false + // positive, but the constant is the better design anyway: one named value + // decides where packages come from, and it has to stay in step with + // Egress.DEFAULT_RULES. + expect(InstallerR.REPO).toBe("https://cran.r-project.org") +}) + +test("the index CRAN is allowlisted, or every R install fails closed", async () => { + const { Egress } = await import("../../src/sandbox/egress") + const host = new URL(InstallerR.REPO).hostname + const allowed = Egress.allowed(host, Egress.DEFAULT_RULES) + expect(allowed).toBe(true) +}) + +test("the install targets R_LIBS_USER, never the system library", async () => { + const source = await read("../../src/package/installer-r.ts") + // Writing to the system library would need root and would leak this + // environment's packages into every other project on the machine. + expect(source.includes("R_LIBS_USER")).toBe(true) + expect(source.includes("install.packages")).toBe(true) +}) + +test("lib is passed explicitly, not left to .libPaths() ordering", async () => { + const source = await read("../../src/package/installer-r.ts") + // install.packages() otherwise picks the first writable entry of .libPaths(), + // which on a machine with a user library already configured is the wrong + // directory. + expect(source.includes("lib = lib")).toBe(true) +}) + +test("a failed install is detected even though install.packages only warns", async () => { + const source = await read("../../src/package/installer-r.ts") + // install.packages() signals failure with a warning and still exits 0, so + // without the explicit check a missing package reads as success. + expect(source.includes("quit(status = 1)")).toBe(true) +}) + +test("explain names Bioconductor for a package CRAN does not have", () => { + const log = "Warning message:\npackage ‘DESeq2’ is not available for this version of R" + expect(InstallerR.explain(log)).toContain("Bioconductor") +}) + +test("explain surfaces a missing system header rather than the compile spew", () => { + const log = [" fatal error: libxml/parser.h: No such file or directory", " compilation terminated."].join("\n") + const message = InstallerR.explain(log) + expect(message).toContain("libxml/parser.h") + expect(message).toContain("system librar") +}) + +test("explain passes an unrecognised log through rather than inventing a diagnosis", () => { + expect(InstallerR.explain("something nobody anticipated")).toContain("something nobody anticipated") +}) + +test.skipIf(!rscript)("an empty library reports no packages", async () => { + await using dir = await tmpdir() + const env = path.join(dir.path, "renv") + await InstallerR.create(env) + expect(await InstallerR.freeze(env)).toEqual({}) +}) + +test.skipIf(!rscript)( + "a package CRAN does not have fails rather than reporting success", + async () => { + await using dir = await tmpdir() + const env = path.join(dir.path, "renv") + const result = await InstallerR.install({ directory: env, packages: ["definitelyNotARealCranPackage"] }) + expect(result.ok).toBe(false) + expect(await InstallerR.verify(env, ["definitelyNotARealCranPackage"])).toEqual({}) + }, + 600_000, +) + +test.skipIf(!rscript)( + "a real CRAN package installs into the environment library and reports its version", + async () => { + // The gap the existing live tests left: both of them assert FAILURE paths + // (an empty library, a package CRAN does not have), so nothing anywhere + // proved an R install can succeed at all. + // + // `praise` is pure R, a few kilobytes, and has no dependencies — CRAN + // serves Linux packages as source, so anything with compiled code would be + // testing a toolchain rather than this installer. + await using dir = await tmpdir() + const env = path.join(dir.path, "renv") + const result = await InstallerR.install({ directory: env, packages: ["praise"] }) + expect(result.ok, result.log).toBe(true) + + const versions = await InstallerR.verify(env, ["praise"]) + expect(versions["praise"]).toMatch(/^\d/) + + // It landed in the environment's own library, not a system or user one — + // the whole point of passing `lib` explicitly rather than trusting + // .libPaths() ordering. + expect(Object.keys(await InstallerR.freeze(env))).toContain("praise") + expect(fs.existsSync(path.join(Installer.rlibrary(env), "praise"))).toBe(true) + }, + 900_000, +) + +test.skipIf(!rscript)( + "a second package is additive alongside the first", + async () => { + // Mirrors the Python additivity check: the tool decides whether to restart + // kernels from freeze() before and after, so an R install has to report a + // growing set rather than replacing it. + await using dir = await tmpdir() + const env = path.join(dir.path, "renv") + await InstallerR.install({ directory: env, packages: ["praise"] }) + const before = await InstallerR.freeze(env) + await InstallerR.install({ directory: env, packages: ["R6"] }) + const after = await InstallerR.freeze(env) + expect(Object.keys(after)).toContain("praise") + expect(Object.keys(after)).toContain("R6") + const { Environment } = await import("../../src/package/environment") + expect(Environment.additive(before, after)).toBe(true) + }, + 900_000, +) diff --git a/backend/cli/test/package/installer.test.ts b/backend/cli/test/package/installer.test.ts new file mode 100644 index 00000000..9b73b100 --- /dev/null +++ b/backend/cli/test/package/installer.test.ts @@ -0,0 +1,455 @@ +import { expect, test } from "bun:test" +import fs from "fs" +import path from "path" +import { Installer } from "../../src/package/installer" +import { Sandbox } from "../../src/sandbox/sandbox" +import { tmpdir } from "../fixture/fixture" + +const python = Bun.which("python3") + +test("probe prefers an existing environment directory over any tool", async () => { + await using dir = await tmpdir() + const env = path.join(dir.path, "env") + fs.mkdirSync(path.dirname(Installer.interpreter(env)), { recursive: true }) + fs.writeFileSync(Installer.interpreter(env), "") + expect((await Installer.probe(env)).kind).toBe("existing") +}) + +test("probe picks uv over venv when both are available", async () => { + await using dir = await tmpdir() + // uv is the fast path when present; venv is the guarantee that it is never + // required. + const tool = await Installer.probe(path.join(dir.path, "nothing"), { uv: "/fake/uv", python: "/fake/python3" }) + expect(tool).toEqual({ kind: "uv", binary: "/fake/uv" }) +}) + +test("probe falls back to venv when uv is absent", async () => { + await using dir = await tmpdir() + const tool = await Installer.probe(path.join(dir.path, "nothing"), { uv: undefined, python: "/fake/python3" }) + expect(tool).toEqual({ kind: "venv", binary: "/fake/python3" }) +}) + +test("the remedy names both routes, and never offers to download one", async () => { + await using dir = await tmpdir() + // An opaque failure here reads as a broken machine — the exact symptom this + // whole design started from, where a missing pip, a severed network and a + // read-only site-packages all surfaced as one unreadable error. + const message = await Installer.probe(path.join(dir.path, "nothing"), { uv: undefined, python: undefined }).then( + () => "", + (error: Error) => error.message, + ) + expect(message).toContain("python3-venv") + expect(message).toContain("uv") + expect(message).toContain("never downloads") +}) + +test.skipIf(!python)("creates a venv whose interpreter runs", async () => { + await using dir = await tmpdir() + const env = path.join(dir.path, "env") + await Installer.create(env, { kind: "venv", binary: python! }) + const proc = Bun.spawn([Installer.interpreter(env), "--version"], { stdout: "pipe" }) + expect(await new Response(proc.stdout).text()).toContain("Python 3") +}) + +test.skipIf(!python)("a fresh venv has pip even when the host python3 does not", async () => { + // Verified on Arch during design: python3 ships without pip there, and + // `python3 -m venv` still bootstraps pip from the bundled ensurepip wheel, + // offline. uv is a fast path, never a requirement. + await using dir = await tmpdir() + const env = path.join(dir.path, "env") + await Installer.create(env, { kind: "venv", binary: python! }) + const proc = Bun.spawn([Installer.interpreter(env), "-m", "pip", "--version"], { stdout: "pipe", stderr: "pipe" }) + await proc.exited + expect(proc.exitCode).toBe(0) +}) + +const uv = Bun.which("uv") + +test.skipIf(!uv)( + "an environment created by the uv branch has pip, because install() needs it", + async () => { + // Regression, and the reason `uv venv --seed` exists in create(). Plain + // `uv venv` does NOT bootstrap pip the way `python3 -m venv` does, while + // install() shells out to `python -m pip` regardless of who created the + // environment. Without the seed the uv branch produced an environment the + // installer could not use at all — "No module named pip" from a venv that + // looked perfectly healthy from outside the sandbox. + await using dir = await tmpdir() + const env = path.join(dir.path, "env") + await Installer.create(env, { kind: "uv", binary: uv! }) + const proc = Bun.spawn([Installer.interpreter(env), "-m", "pip", "--version"], { stdout: "pipe", stderr: "pipe" }) + await proc.exited + expect(proc.exitCode).toBe(0) + }, + 120_000, +) + +// The regression this pair exists for, measured in real use: a kernel binds to +// the managed environment as soon as one exists and falls back to the host +// interpreter while it does not, so the FIRST install of anything used to strip +// every host package from every kernel in the project. Install tqdm, lose numpy +// — while the notebook tool still advertised numpy as pre-imported. +const hostHas = (name: string) => { + const proc = Bun.spawnSync([python ?? "python3", "-c", `import ${name}`], { stdout: "ignore", stderr: "ignore" }) + return proc.exitCode === 0 +} + +test.skipIf(!python || !hostHas("numpy"))( + "a fresh environment can still import what the host interpreter had", + async () => { + await using dir = await tmpdir() + const env = path.join(dir.path, "env") + await Installer.create(env, { kind: "venv", binary: python! }) + const proc = Bun.spawn([Installer.interpreter(env), "-c", "import numpy"], { stdout: "ignore", stderr: "pipe" }) + const err = await new Response(proc.stderr).text() + await proc.exited + expect(proc.exitCode, err).toBe(0) + }, + 120_000, +) + +test.skipIf(!python || !hostHas("numpy"))( + "verify reports an inherited package, because the kernel can genuinely use it", + async () => { + // freeze() lists only what the environment OWNS. Since environments inherit + // system site-packages, pip treats a host-provided package as already + // satisfied and installs nothing — so a freeze-based verify answered + // "(nothing reported)" for a request that is, from the user's seat, + // perfectly satisfied. + await using dir = await tmpdir() + const env = path.join(dir.path, "env") + await Installer.create(env, { kind: "venv", binary: python! }) + expect(Object.keys(await Installer.freeze(env))).not.toContain("numpy") + expect((await Installer.verify(env, ["numpy"]))["numpy"]).toMatch(/^\d/) + }, + 120_000, +) + +test.skipIf(!python)( + "verify still reports nothing for a package that is genuinely absent", + async () => { + await using dir = await tmpdir() + const env = path.join(dir.path, "env") + await Installer.create(env, { kind: "venv", binary: python! }) + expect(await Installer.verify(env, ["definitely-not-a-real-distribution-xyzzy"])).toEqual({}) + }, + 120_000, +) + +test.skipIf(!python || !hostHas("numpy"))( + "freeze reports only what the environment owns, not the whole host", + async () => { + // Otherwise `total` is a fact about the machine, the agent's inventory is + // buried under host packages, and additive() compares against the wrong set. + await using dir = await tmpdir() + const env = path.join(dir.path, "env") + await Installer.create(env, { kind: "venv", binary: python! }) + expect(Object.keys(await Installer.freeze(env))).not.toContain("numpy") + }, + 120_000, +) + +test("whichever branch of the ladder creates it, the environment must expose pip", async () => { + // The invariant the bug violated, stated once so a future third branch has + // to satisfy it too rather than quietly repeating the same mistake. + const source = await Bun.file(new URL("../../src/package/installer.ts", import.meta.url).pathname).text() + expect(source.includes('"--seed"')).toBe(true) +}) + +test.skipIf(!python)("create on an existing environment is a no-op, not a rebuild", async () => { + await using dir = await tmpdir() + const env = path.join(dir.path, "env") + await Installer.create(env, { kind: "venv", binary: python! }) + const marker = path.join(env, "marker") + fs.writeFileSync(marker, "keep me") + await Installer.create(env, await Installer.probe(env)) + // Rebuilding would silently discard everything already installed. + expect(fs.existsSync(marker)).toBe(true) +}) + +test.skipIf(!python)("freeze reports name to version for what is installed", async () => { + await using dir = await tmpdir() + const env = path.join(dir.path, "env") + await Installer.create(env, { kind: "venv", binary: python! }) + const frozen = await Installer.freeze(env) + expect(Object.values(frozen).every((v) => /^\d/.test(v))).toBe(true) +}) + +test.skipIf(!python)("freeze normalises names so they compare against parsed requirements", async () => { + await using dir = await tmpdir() + const env = path.join(dir.path, "env") + await Installer.create(env, { kind: "venv", binary: python! }) + const frozen = await Installer.freeze(env) + // Environment.additive compares these keys against Requirement.parse output, + // so both sides must be PEP 503 normalised or an upgrade looks additive. + expect(Object.keys(frozen).every((k) => k === k.toLowerCase() && !k.includes("_"))).toBe(true) +}) + +test.skipIf(!python)("verify reports the version of a module that is present", async () => { + await using dir = await tmpdir() + const env = path.join(dir.path, "env") + await Installer.create(env, { kind: "venv", binary: python! }) + expect((await Installer.verify(env, ["pip"]))["pip"]).toMatch(/^\d/) +}) + +test.skipIf(!python)("verify reports nothing for a module that is absent", async () => { + // Catches an installer that exits 0 without producing a working module. + await using dir = await tmpdir() + const env = path.join(dir.path, "env") + await Installer.create(env, { kind: "venv", binary: python! }) + expect( + (await Installer.verify(env, ["definitely-not-a-real-module"]))["definitely-not-a-real-module"], + ).toBeUndefined() +}) + +// install() is the load-bearing function in this module and everything above +// only exercises what surrounds it. Task 11 proves the whole path end to end; +// these two prove the sandboxed argv composes and runs at all, here, where a +// break is cheap to localise. +const sandboxed = Sandbox.backend() !== "none" && Boolean(python) + +test.skipIf(!sandboxed)( + "a real install through the sandbox lands a real package", + async () => { + await using dir = await tmpdir() + const env = path.join(dir.path, "env") + await Installer.create(env, await Installer.probe(env)) + const result = await Installer.install({ directory: env, packages: ["tqdm"], index: "", source: false }) + expect(result.ok, result.log).toBe(true) + // Not "pip exited 0" — the version has to come back out of the environment. + expect((await Installer.verify(env, ["tqdm"]))["tqdm"]).toMatch(/^\d/) + }, + 300_000, +) + +test.skipIf(!sandboxed)( + "a failed install reports ok:false and a log, and lands nothing", + async () => { + await using dir = await tmpdir() + const env = path.join(dir.path, "env") + await Installer.create(env, await Installer.probe(env)) + const before = await Installer.freeze(env) + const result = await Installer.install({ + directory: env, + packages: ["this-package-does-not-exist-anywhere-xyzzy"], + index: "", + source: false, + }) + expect(result.ok).toBe(false) + expect(result.log.length).toBeGreaterThan(0) + // Modern pip builds every wheel before the install phase, so a failure + // aborts before anything is committed. There is no subset to keep. + expect(await Installer.freeze(env)).toEqual(before) + }, + 300_000, +) + +test("explain translates the wheels-only rejection into what it means", () => { + const log = "ERROR: Could not find a version that satisfies the requirement foo (from versions: none)" + const message = Installer.explain(log) + // Reads as "no such package" but means "no wheel under this policy". + expect(message).toContain("No wheel") + expect(message).toContain("source") + expect(message).toContain("foo") +}) + +test("explain surfaces the cause of a build failure, not pip's summary line", () => { + const log = [ + " #include ", + " ^~~~~~~~~~", + " fatal error: Python.h: No such file or directory", + " compilation terminated.", + " ERROR: Failed building wheel for cffi", + ].join("\n") + const message = Installer.explain(log) + // The summary names the package; the fatal error names the actual missing + // piece, which is what decides whether this is achievable in a sandbox. + expect(message).toContain("Python.h") + expect(message).toContain("cffi") +}) + +test("explain passes an unrecognised log through rather than inventing a diagnosis", () => { + expect(Installer.explain("ERROR: something nobody anticipated")).toContain("something nobody anticipated") +}) + +test.skipIf(!sandboxed)( + "install reports progress as pip works, not only at the end", + async () => { + // The defect this exists for: a pytorch install sat behind an unchanging + // ellipsis for 1m37s while pip reported phase and size the whole time, + // because the output was buffered and only read on completion. + await using dir = await tmpdir() + const env = path.join(dir.path, "env") + await Installer.create(env, await Installer.probe(env)) + const seen: string[] = [] + const result = await Installer.install({ + directory: env, + packages: ["tqdm"], + index: "", + source: false, + onProgress: (s) => seen.push(s), + }) + expect(result.ok, result.log).toBe(true) + expect(seen.length).toBeGreaterThan(0) + // Real pip phrasing, not a placeholder the tool invented. + expect(seen.join("\n")).toMatch(/Collecting|Downloading|Installing|Successfully/i) + // And the full log still survives for explain(), which needs lines that are + // rarely last. + expect(result.log.length).toBeGreaterThan(0) + }, + 300_000, +) + +test.skipIf(!sandboxed)( + "a second environment reuses the shared wheel cache instead of re-downloading", + async () => { + // The cache used to live inside the environment directory, so every new + // environment re-downloaded everything — measured at 34 MB and a full + // download for scipy alone, into an environment created seconds after one + // that already had it. The packages where this hurts are the large ones. + await using dir = await tmpdir() + const first = path.join(dir.path, "one") + const second = path.join(dir.path, "two") + await Installer.create(first, await Installer.probe(first)) + await Installer.create(second, await Installer.probe(second)) + + const a = await Installer.install({ directory: first, packages: ["tqdm"], index: "", source: false }) + expect(a.ok, a.log).toBe(true) + + const seen: string[] = [] + const b = await Installer.install({ + directory: second, + packages: ["tqdm"], + index: "", + source: false, + onProgress: (s) => seen.push(s), + }) + expect(b.ok, b.log).toBe(true) + // pip says so itself when it serves from cache rather than the network. + expect(b.log).toMatch(/cached|Using cached/i) + // And neither install put a cache inside the environment it populated. + expect(fs.existsSync(path.join(second, ".cache"))).toBe(false) + }, + 600_000, +) + +// `source: true` had never been exercised anywhere — not in tests, not in the +// product — while explain() actively tells users "Retry with source builds +// enabled if a compiler and headers are available". A user following our own +// error message would have been the first to run this path. +// +// sgmllib3k is published as an sdist with no wheel, so it is refused under the +// default wheels-only policy and installs only when source builds are allowed. +// That makes the flag's effect observable rather than asserted from argv. +test.skipIf(!sandboxed)( + "wheels-only refuses an sdist-only package, and says what it really means", + async () => { + await using dir = await tmpdir() + const env = path.join(dir.path, "env") + await Installer.create(env, await Installer.probe(env)) + const result = await Installer.install({ + directory: env, + packages: ["sgmllib3k"], + index: "", + source: false, + }) + expect(result.ok).toBe(false) + // The raw log reads as "no such package"; the translation has to say the + // truth, which is that a wheel is missing and source builds are the answer. + const message = Installer.explain(result.log) + expect(message).toContain("No wheel") + expect(message).toContain("source") + expect(await Installer.verify(env, ["sgmllib3k"])).toEqual({}) + }, + 600_000, +) + +test.skipIf(!sandboxed)( + "the same package installs when source builds are allowed", + async () => { + // The escalation explain() advertises, actually performed: a real sdist + // built inside the sandbox, through the allowlist proxy. + await using dir = await tmpdir() + const env = path.join(dir.path, "env") + await Installer.create(env, await Installer.probe(env)) + const result = await Installer.install({ + directory: env, + packages: ["sgmllib3k"], + index: "", + source: true, + }) + expect(result.ok, result.log).toBe(true) + expect((await Installer.verify(env, ["sgmllib3k"]))["sgmllib3k"]).toMatch(/^\d/) + }, + 600_000, +) + +// The version of a package the host interpreter already provides, or undefined. +// The bug below only appears when the requested version MATCHES the host's, so +// the test has to discover that version rather than hardcode one. +const hostVersion = (name: string) => { + const proc = Bun.spawnSync( + [python ?? "python3", "-c", `import importlib.metadata as m; print(m.version(${JSON.stringify(name)}))`], + { stdout: "pipe", stderr: "ignore" }, + ) + const text = proc.stdout.toString().trim() + return proc.exitCode === 0 && /^\d/.test(text) ? text : undefined +} + +test.skipIf(!sandboxed || !hostVersion("six"))( + "a version change is not additive even when the host already provided the old one", + async () => { + // The bug CI caught, and the reason `resolved()` exists apart from + // `freeze()`. Requesting the exact version the host provides installs + // nothing locally, so an owned-set comparison sees no `six` in the "before" + // snapshot and reads the next version as an ADDITION. Kernels holding a + // stale six in memory were then never restarted — the silent staleness the + // whole restart rule exists to prevent. + const host = hostVersion("six")! + const other = host === "1.17.0" ? "1.16.0" : "1.17.0" + + await using dir = await tmpdir() + const env = path.join(dir.path, "env") + await Installer.create(env, { kind: "venv", binary: python! }) + + const first = await Installer.install({ directory: env, packages: [`six==${host}`], index: "", source: false }) + expect(first.ok, first.log).toBe(true) + + const before = await Installer.resolved(env) + // The precondition that makes this test meaningful: the environment owns + // nothing, because the host already satisfied the request. + expect(Object.keys(await Installer.freeze(env))).not.toContain("six") + expect(before["six"]).toBe(host) + + const second = await Installer.install({ directory: env, packages: [`six==${other}`], index: "", source: false }) + expect(second.ok, second.log).toBe(true) + + const after = await Installer.resolved(env) + expect(after["six"]).toBe(other) + const { Environment } = await import("../../src/package/environment") + expect(Environment.additive(before, after)).toBe(false) + + // And the contrast that makes this a regression test rather than an + // assertion: comparing OWNED sets — what the code did before — calls the + // very same change additive, because the environment owned no six until the + // second install. Both lines have to stay true for the bug to be gone. + const ownedBefore = {} as Record + const ownedAfter = await Installer.freeze(env) + expect(ownedAfter["six"]).toBe(other) + expect(Environment.additive(ownedBefore, ownedAfter)).toBe(true) + }, + 600_000, +) + +test.skipIf(!python || !hostHas("numpy"))( + "resolved sees inherited packages, freeze does not", + async () => { + // The invariant behind the fix, stated once: two questions, two answers. + await using dir = await tmpdir() + const env = path.join(dir.path, "env") + await Installer.create(env, { kind: "venv", binary: python! }) + expect(Object.keys(await Installer.resolved(env))).toContain("numpy") + expect(Object.keys(await Installer.freeze(env))).not.toContain("numpy") + }, + 120_000, +) diff --git a/backend/cli/test/package/prompt.test.ts b/backend/cli/test/package/prompt.test.ts new file mode 100644 index 00000000..4e083afa --- /dev/null +++ b/backend/cli/test/package/prompt.test.ts @@ -0,0 +1,132 @@ +import { expect, test } from "bun:test" +import { PackagePrompt } from "../../src/package/prompt" +import { SystemPrompt } from "../../src/session/system" + +test("packages() returns the capability block, shaped like compute()", async () => { + const block = await SystemPrompt.packages("proj_empty_for_shape") + expect(block).toHaveLength(1) + expect(block[0]).toContain("") + expect(block[0]).toContain("") +}) + +test("an empty inventory tells the agent the first install creates one", () => { + const rendered = PackagePrompt.render({ environments: [] }) + expect(rendered).toContain("No environments exist yet") +}) + +test("an inventory lists requested packages only, with a dependency count", () => { + const rendered = PackagePrompt.render({ + environments: [{ name: "default", language: "python", requested: ["numpy", "pandas"], total: 168, busy: false }], + }) + expect(rendered).toContain("default (python): numpy, pandas (+166 deps)") + // The resolved closure is dominated by libgcc/harfbuzz/qt6-main and would + // bury the contract in font libraries. + expect(rendered).not.toContain("libgcc") +}) + +test("a busy environment is flagged so the agent does not execute into it", () => { + const rendered = PackagePrompt.render({ + environments: [{ name: "default", language: "python", requested: [], total: 0, busy: true }], + }) + expect(rendered).toContain("INSTALL IN PROGRESS") +}) + +test("the contract promises refusal, not a missing network", () => { + const rendered = PackagePrompt.render({ environments: [] }) + // The old wording said "the agent shell has no network", which the allowlist + // proxy made false — and it implied the venv-in-workspace route was + // impossible when it is exactly what works. + expect(rendered).not.toContain("no network") + expect(rendered).toContain("refused") + expect(rendered).toContain("virtualenv you create yourself") +}) + +test("the inventory reflects a real written environment", async () => { + const { Environment } = await import("../../src/package/environment") + const project = "proj_inventory" + await Environment.write(project, { + name: "torch", + language: "python", + requested: ["torch"], + installed: { torch: "2.4.0", filelock: "3.15.4" }, + total: 2, + createdAt: 1, + updatedAt: 1, + }) + const rendered = await PackagePrompt.system(project) + expect(rendered).toContain("torch (python): torch (+1 deps)") +}) + +test("a busy environment is reported from the live lock, not a stored flag", async () => { + const { Environment } = await import("../../src/package/environment") + const project = "proj_busy" + await Environment.write(project, { + name: "held", + language: "python", + requested: [], + installed: {}, + total: 0, + createdAt: 1, + updatedAt: 1, + }) + let rendered = "" + await Environment.lock(project, "held", async () => { + rendered = await PackagePrompt.system(project) + }) + // A stored flag would survive a crash and permanently mark a healthy + // environment busy. The lock lives in memory and is the truth. + expect(rendered).toContain("INSTALL IN PROGRESS") + expect(await PackagePrompt.system(project)).not.toContain("INSTALL IN PROGRESS") +}) + +test("an unknown project renders the empty inventory rather than throwing", async () => { + expect(await PackagePrompt.system("proj_never_seen")).toContain("No environments exist yet") +}) + +test("after a real install, the agent's contract lists what it installed", async () => { + // The whole point of this task. Before it, `system()` read a global + // environments.json that nothing wrote, so the agent was told "No + // environments exist yet" forever — including immediately after installing + // something — which makes the contract's first rule ("answer whether a + // package is available from the inventory above") actively misleading. + const { Sandbox } = await import("../../src/sandbox/sandbox") + if (Sandbox.backend() === "none" || !Bun.which("python3")) return + const { Instance } = await import("../../src/project/instance") + const { PackageTool } = await import("../../src/tool/package") + const { executionSession, tmpdir } = await import("../fixture/fixture") + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await executionSession() + const tool = await PackageTool.init() + const before = await PackagePrompt.system(Instance.project.id) + expect(before).toContain("No environments exist yet") + + await tool.execute({ packages: ["tqdm"], environment: "seen", language: "python", source: false, wait: true }, { + sessionID: session.id, + messageID: "", + callID: "", + agent: "research", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, + } as never) + + const after = await PackagePrompt.system(Instance.project.id) + expect(after).not.toContain("No environments exist yet") + expect(after).toContain("seen (python): tqdm") + }, + }) +}, 600_000) + +test("the injection is unconditional, beside compute()", async () => { + // The load-bearing mechanism is that this reaches EVERY request for EVERY + // agent — not a skill override, which only reaches a skill's front page and + // never its reference files or a third-party skill cloned from GitHub. + const source = await Bun.file(new URL("../../src/session/prompt.ts", import.meta.url).pathname).text() + // A boolean, not toContain(source): a failing toContain prints the whole + // 86KB file into the runner output and buries every other result. + expect(source.includes("await SystemPrompt.packages()")).toBe(true) +}) diff --git a/backend/cli/test/package/refuse.test.ts b/backend/cli/test/package/refuse.test.ts new file mode 100644 index 00000000..690fa737 --- /dev/null +++ b/backend/cli/test/package/refuse.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from "bun:test" +import { Refuse } from "../../src/package/refuse" + +test.each([ + ["pip install numpy"], + ["pip3 install numpy"], + ["pip install -r requirements.txt"], + ["uv pip install numpy"], + ["conda install numpy"], + ["mamba install numpy"], + ["poetry add numpy"], + // The venv-in-workspace route, which is what actually works today and what + // a bare "pip install" match misses entirely. + ["/work/project/venv/bin/pip install numpy"], + ["./venv/bin/pip install numpy"], + ["python -m pip install numpy"], + ["python3 -m pip install numpy"], + ["/usr/bin/python3.14 -m pip install numpy"], + ["./venv/bin/python -m pip install numpy"], +])("refuses %s", (line) => { + const message = Refuse.installer(line.split(" ")) + expect(message).toBeString() + expect(message).toContain("package_install") +}) + +test.each([ + // Read-only inspection stays allowed: refusing these would break ordinary + // work and teach the agent that the whole tool is unreliable. + ["pip list"], + ["pip show numpy"], + ["pip --version"], + ["python -m pip list"], + ["conda env list"], + // Not installers at all. + ["python analysis.py"], + ["npm install"], + ["git install-hooks"], + ["echo pip install numpy"], +])("allows %s", (line) => { + expect(Refuse.installer(line.split(" "))).toBeUndefined() +}) + +test("the message names the tool and the reason, not just a denial", () => { + const message = Refuse.installer(["pip", "install", "numpy"])! + expect(message).toContain("package_install") + expect(message).toContain("numpy") +}) diff --git a/backend/cli/test/package/requirement.test.ts b/backend/cli/test/package/requirement.test.ts new file mode 100644 index 00000000..f1063140 --- /dev/null +++ b/backend/cli/test/package/requirement.test.ts @@ -0,0 +1,146 @@ +import { expect, test } from "bun:test" +import { Requirement } from "../../src/package/requirement" + +test("a bare name", () => { + expect(Requirement.parse("numpy")).toEqual({ name: "numpy", extras: [], specifier: "", marker: "", url: "" }) +}) + +test("a version specifier is kept whole, not split on ==", () => { + // The exact case a naive `split("==")` gets wrong. + expect(Requirement.parse("numpy>=2.4")).toMatchObject({ name: "numpy", specifier: ">=2.4" }) +}) + +test.each([ + ["numpy==2.1.0", "==2.1.0"], + ["numpy!=2.0.0", "!=2.0.0"], + ["numpy~=2.1", "~=2.1"], + ["numpy<3", "<3"], + ["numpy<=3", "<=3"], + ["numpy>2", ">2"], + ["numpy===2.1.0", "===2.1.0"], + ["numpy>=2.1,<3", ">=2.1,<3"], +])("parses the specifier in %s", (input, specifier) => { + expect(Requirement.parse(input)).toMatchObject({ name: "numpy", specifier }) +}) + +test("extras are captured and not folded into the name", () => { + expect(Requirement.parse("pandas[performance,excel]")).toMatchObject({ + name: "pandas", + extras: ["performance", "excel"], + }) +}) + +test("extras combine with a specifier", () => { + expect(Requirement.parse("pandas[performance]>=2.2")).toMatchObject({ + name: "pandas", + extras: ["performance"], + specifier: ">=2.2", + }) +}) + +test("an environment marker is separated from the specifier", () => { + expect(Requirement.parse('tqdm>=4 ; python_version >= "3.9"')).toMatchObject({ + name: "tqdm", + specifier: ">=4", + marker: 'python_version >= "3.9"', + }) +}) + +test("a direct URL reference keeps the name and the url apart", () => { + expect(Requirement.parse("mypkg @ https://example.com/mypkg-1.0-py3-none-any.whl")).toMatchObject({ + name: "mypkg", + url: "https://example.com/mypkg-1.0-py3-none-any.whl", + }) +}) + +test("names normalise per PEP 503 so Foo_Bar and foo-bar are one package", () => { + // Treating them as different packages would let an upgrade look additive. + expect(Requirement.parse("Foo_Bar").name).toBe("foo-bar") + expect(Requirement.parse("Foo.Bar").name).toBe("foo-bar") + expect(Requirement.parse("FOO---BAR").name).toBe("foo-bar") +}) + +test.each([[""], [" "], ["=="], ["numpy=="], ["-rrequirements.txt"], ["numpy >= "], ["[extras]"], ["@ https://x"]])( + "rejects %p rather than guessing", + (input) => { + // A silently mis-parsed name becomes a wrong permission pattern, and a + // wrong pattern approves something other than what runs. + expect(() => Requirement.parse(input)).toThrow() + }, +) + +test.each([["numpy >= "], ["numpy>="], ["numpy<="], ["numpy=="], ["numpy~="], ["numpy>=2.1,"], ["numpy>=2.1,<"]])( + "rejects the dangling operator in %p", + (input) => { + // Regression: a prefix test like /^(===|==|…|>|<)\s*\S/ accepts these, + // because the alternation backtracks to the single-character `>` and + // consumes the `=` as the version. The clause regex is anchored end to end + // precisely to stop that — a dangling operator would otherwise reach pip + // as a literal requirement, having passed validation. + expect(() => Requirement.parse(input)).toThrow() + }, +) + +test("a multi-clause specifier is validated clause by clause", () => { + expect(Requirement.parse("numpy>=2.1,<3").specifier).toBe(">=2.1,<3") + expect(() => Requirement.parse("numpy>=2.1,<")).toThrow() +}) + +test("the canonical pattern is exactly the spec's string", () => { + expect(Requirement.pattern({ packages: ["numpy", "pandas"], environment: "default", index: "pypi.org/simple" })).toBe( + "install numpy pandas → default [pypi.org/simple]", + ) +}) + +test("the pattern is stable under argument order, so the same request matches the same grant", () => { + const a = Requirement.pattern({ packages: ["pandas", "numpy"], environment: "default", index: "pypi.org/simple" }) + const b = Requirement.pattern({ packages: ["numpy", "pandas"], environment: "default", index: "pypi.org/simple" }) + expect(a).toBe(b) +}) + +test("the pattern drops version specifiers, matching what the card shows", () => { + // Resolution happens after approval — the card shows the request, so pinning + // a version must not fragment an existing grant. + const a = Requirement.pattern({ packages: ["numpy>=2.4"], environment: "default", index: "pypi.org/simple" }) + const b = Requirement.pattern({ packages: ["numpy"], environment: "default", index: "pypi.org/simple" }) + expect(a).toBe(b) +}) + +test("changing the environment changes the pattern, so the prompt reappears", () => { + const a = Requirement.pattern({ packages: ["numpy"], environment: "default", index: "pypi.org/simple" }) + const b = Requirement.pattern({ packages: ["numpy"], environment: "torch", index: "pypi.org/simple" }) + expect(a).not.toBe(b) +}) + +test("changing the index changes the pattern", () => { + const a = Requirement.pattern({ packages: ["numpy"], environment: "default", index: "pypi.org/simple" }) + const b = Requirement.pattern({ packages: ["numpy"], environment: "default", index: "pypi.internal/simple" }) + expect(a).not.toBe(b) +}) + +test("index credentials are redacted, never shown on the card", () => { + const redacted = Requirement.redact("https://user:s3cret@pypi.internal/simple") + expect(redacted).toBe("pypi.internal/simple") + expect(redacted).not.toContain("s3cret") + expect(redacted).not.toContain("user") +}) + +test("a bare index is unchanged by redaction apart from its scheme", () => { + expect(Requirement.redact("https://pypi.org/simple/")).toBe("pypi.org/simple") +}) + +test("a credentialled index and a differently-credentialled one produce the same pattern", () => { + // Credentials are environment config, not part of the approved action — + // rotating a token must not invalidate a standing grant. + const a = Requirement.pattern({ + packages: ["numpy"], + environment: "default", + index: Requirement.redact("https://user:s3cret@pypi.internal/simple"), + }) + const b = Requirement.pattern({ + packages: ["numpy"], + environment: "default", + index: Requirement.redact("https://other:tok@pypi.internal/simple"), + }) + expect(a).toBe(b) +}) diff --git a/backend/cli/test/package/tool.test.ts b/backend/cli/test/package/tool.test.ts new file mode 100644 index 00000000..638e2d97 --- /dev/null +++ b/backend/cli/test/package/tool.test.ts @@ -0,0 +1,217 @@ +import { expect, test } from "bun:test" +import { Environment } from "../../src/package/environment" +import { Requirement } from "../../src/package/requirement" +import type { PermissionNext } from "../../src/permission/next" +import { Instance } from "../../src/project/instance" +import { Sandbox } from "../../src/sandbox/sandbox" +import { executionSession, tmpdir } from "../fixture/fixture" + +const read = (relative: string) => Bun.file(new URL(relative, import.meta.url).pathname).text() + +test("the approval pattern is the canonical command string the card shows", () => { + // The card and the permission matcher must use the ONE string. If they ever + // diverge, the user approves one thing and another runs. + expect(Requirement.pattern({ packages: ["numpy", "pandas"], environment: "default", index: "pypi.org/simple" })).toBe( + "install numpy pandas → default [pypi.org/simple]", + ) +}) + +test("the tool asks with the package_install capability and the install* grant", async () => { + const source = await read("../../src/tool/package.ts") + // Pinned as constants because the spec pins them: the capability was + // reserved in trust.ts and execution.ts with zero call sites, and the + // standing grant mirrors notebook.ts, which shows "python (notebook)" and + // stores the broad "python*". + expect(source.includes('permission: "package_install"')).toBe(true) + expect(source.includes('always: ["install*"]')).toBe(true) +}) + +test("the card is asked for before anything is installed", async () => { + const source = await read("../../src/tool/package.ts") + // Approval precedes the lock and the installer, not the other way round. + expect(source.indexOf("ctx.ask")).toBeGreaterThan(-1) + expect(source.indexOf("ctx.ask")).toBeLessThan(source.indexOf("Installer.install")) +}) + +test("resolution happens after approval, so the card shows the request", async () => { + const source = await read("../../src/tool/package.ts") + // Approving 2 names must not silently approve the 168-entry closure. + expect(source.indexOf("ctx.ask")).toBeLessThan(source.indexOf("Installer.freeze")) +}) + +test("the tool is registered", async () => { + const source = await read("../../src/tool/registry.ts") + expect(source.includes("PackageTool")).toBe(true) +}) + +test("installs are not a paid action, so no spendFilter entry exists", async () => { + const source = await read("../../src/permission/next.ts") + // Governing principle: nothing is gated more strictly than arbitrary code + // execution unless it costs money. An install costs nothing. + expect(source.includes("package_install")).toBe(false) +}) + +test("the tool exists and declares its parameters", async () => { + const { PackageTool } = await import("../../src/tool/package") + expect(PackageTool.id).toBe("package_install") +}) + +// Everything above reads the source. These run the tool. Source assertions +// cannot tell whether the card actually fires, and the card is the entire +// point of this task. + +async function context() { + const session = await executionSession() + const asks: Array> = [] + return { + asks, + ctx: { + sessionID: session.id, + messageID: "", + callID: "", + agent: "research", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async (req: Omit) => { + asks.push(req) + }, + }, + } +} + +const python = Bun.which("python3") +const live = Sandbox.backend() !== "none" && Boolean(python) + +test.skipIf(!live)( + "installing asks for approval with the canonical pattern, then lands the package", + async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { PackageTool } = await import("../../src/tool/package") + const tool = await PackageTool.init() + const { asks, ctx } = await context() + const result = await tool.execute( + { packages: ["tqdm"], environment: "t1", language: "python" as const, source: false, wait: true }, + ctx, + ) + + expect(asks).toHaveLength(1) + expect(asks[0]!.permission).toBe("package_install") + expect(asks[0]!.patterns).toEqual(["install tqdm → t1 [pypi.org/simple]"]) + expect(asks[0]!.always).toEqual(["install*"]) + + expect(result.metadata.ok).toBe(true) + expect(result.metadata.versions["tqdm"]).toMatch(/^\d/) + // A first install into an empty environment is additive by definition. + expect(result.metadata.additive).toBe(true) + + // The manifest records the request, not the closure. + const stored = await Environment.read(Instance.project.id, "t1") + expect(stored?.requested).toEqual(["tqdm"]) + expect(stored!.total).toBeGreaterThan(0) + }, + }) + }, + 300_000, +) + +test.skipIf(!live)( + "a fully-satisfied request installs nothing and never shows a card", + async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { PackageTool } = await import("../../src/tool/package") + const tool = await PackageTool.init() + const first = await context() + await tool.execute( + { packages: ["tqdm"], environment: "t2", language: "python" as const, source: false, wait: true }, + first.ctx, + ) + + const second = await context() + const result = await tool.execute( + { packages: ["tqdm"], environment: "t2", language: "python" as const, source: false, wait: true }, + second.ctx, + ) + // Nothing privileged happens, so nothing needs approving — and a + // fully-satisfied request is not worth a turn. + expect(second.asks).toHaveLength(0) + expect(result.metadata.installed).toBe(false) + }, + }) + }, + 300_000, +) + +test.skipIf(!live)( + "a pinned version is never treated as already satisfied by a different one", + async () => { + // Regression. The skip check compared package NAMES only, so + // `six==1.17.0` against an installed 1.16.0 returned "already installed", + // skipped the install, and reported the change as additive — leaving the + // environment on the old version while telling the agent it had the new + // one, and leaving bound kernels un-restarted. + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { PackageTool } = await import("../../src/tool/package") + const tool = await PackageTool.init() + await tool.execute( + { packages: ["six==1.16.0"], environment: "pin", language: "python", source: false, wait: true }, + (await context()).ctx, + ) + const upgrade = await context() + const result = await tool.execute( + { packages: ["six==1.17.0"], environment: "pin", language: "python", source: false, wait: true }, + upgrade.ctx, + ) + // It really ran, it really asked, and it knows the change was not additive. + expect(upgrade.asks).toHaveLength(1) + expect(result.metadata.installed).toBe(true) + expect(result.metadata.additive).toBe(false) + expect(result.metadata.versions["six"]).toBe("1.17.0") + }, + }) + }, + 600_000, +) + +test.skipIf(!live)( + "a failed install throws the translated cause and writes no manifest", + async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { PackageTool } = await import("../../src/tool/package") + const tool = await PackageTool.init() + const { ctx } = await context() + const failure = await tool + .execute( + { + packages: ["this-package-does-not-exist-anywhere-xyzzy"], + environment: "t3", + language: "python" as const, + source: false, + wait: true, + }, + ctx, + ) + .then( + () => undefined, + (error: Error) => error, + ) + expect(failure).toBeDefined() + // Nothing landed, so nothing is recorded as landed. + expect(await Environment.read(Instance.project.id, "t3")).toBeUndefined() + }, + }) + }, + 300_000, +) diff --git a/backend/cli/test/project/execution-authority.test.ts b/backend/cli/test/project/execution-authority.test.ts index 54f17396..bb29e53a 100644 --- a/backend/cli/test/project/execution-authority.test.ts +++ b/backend/cli/test/project/execution-authority.test.ts @@ -79,7 +79,7 @@ test("read-only project authority rejects terminal, shell, and kernel before pro trustRevision: 2, sandbox: { enabled: true, - network: "deny", + network: "allowlist", onUnavailable: "error", }, }) @@ -190,7 +190,7 @@ test("trusted terminal derives its process contract from the owning session", as sandbox: { enabled: true, enforced: true, - network: "deny", + network: "allowlist", }, }, status: "running", diff --git a/backend/cli/test/sandbox/appcontainer.test.ts b/backend/cli/test/sandbox/appcontainer.test.ts new file mode 100644 index 00000000..004fe1e0 --- /dev/null +++ b/backend/cli/test/sandbox/appcontainer.test.ts @@ -0,0 +1,123 @@ +import { expect, test } from "bun:test" +import { AppContainer } from "../../src/sandbox/appcontainer" +import { Sandbox } from "../../src/sandbox/sandbox" + +/** + * What can be tested without Windows. + * + * The Win32 calls cannot run here, and pretending otherwise would be worse than + * admitting it — the probe already measured that sequence on a real machine. + * What IS testable is everything around them: the spec round trip, the + * UTF-16 encoding those `...W` entry points require, and the command-line + * quoting, which is where a silent mistake would hide. `CommandLineToArgvW` + * re-splits a single string with rules that are neither the shell's nor + * POSIX's, so a path like `C:\Users\me\My Project\` can quietly change what the + * child executes rather than failing loudly. + */ + +test("the spec survives the base64 round trip Sandbox composes", () => { + const policy = { + writable: ["C:\\work\\project"], + unreadable: ["C:\\Users\\me\\.ssh\\id_rsa"], + network: "allowlist" as const, + egress: "openscience-broker-abc", + profile: "openscience-deadbeef", + } + const args = Sandbox.appContainerArgs(policy, ["python.exe", "-u", "k.py"]) + const spec = AppContainer.decode(args[1]!) + expect(spec.profile).toBe("openscience-deadbeef") + expect(spec.writable).toEqual(["C:\\work\\project"]) + expect(spec.unreadable).toEqual(["C:\\Users\\me\\.ssh\\id_rsa"]) + expect(spec.network).toBe("allowlist") + expect(spec.pipe).toBe("openscience-broker-abc") +}) + +test("a spec with no profile is rejected rather than launched unconfined", () => { + const blob = Buffer.from(JSON.stringify({ writable: [], unreadable: [], network: "deny" })).toString("base64") + expect(() => AppContainer.decode(blob)).toThrow("profile") +}) + +test("wide() produces null-terminated UTF-16LE", () => { + // Every ...W entry point reads until a null. A missing terminator reads past + // the buffer; a UTF-8 buffer is silently misinterpreted as UTF-16 pairs. + const buf = AppContainer.wide("Hi") + expect([...buf]).toEqual([0x48, 0x00, 0x69, 0x00, 0x00, 0x00]) +}) + +test("readWide reverses wide(), and stops at the terminator", () => { + const sid = "S-1-15-2-3041870312-880516233" + const buf = AppContainer.wide(sid) + // Trailing garbage after the null must be ignored, the way a real SID buffer + // returned by ConvertSidToStringSid sits inside a larger allocation. + const padded = Buffer.concat([buf, Buffer.from([0x41, 0x00, 0x42, 0x00])]) + expect(AppContainer.readWide(new Uint8Array(padded))).toBe(sid) +}) + +test.each([ + ["plain", "python.exe", "python.exe"], + ["a space", "My Project", '"My Project"'], + ["a quote", 'say"hi', '"say\\"hi"'], + // A trailing backslash before the closing quote must be doubled, or it + // escapes the quote and swallows the next argument. + ["a trailing backslash with a space", "C:\\My Dir\\", '"C:\\My Dir\\\\"'], + ["backslashes before a quote", 'a\\\\"b', '"a\\\\\\\\\\"b"'], + ["backslashes with no quote", "C:\\a\\b", "C:\\a\\b"], +])("quoting %s survives CommandLineToArgvW", (_label, input, expected) => { + expect(AppContainer.quote(input)).toBe(expected) +}) + +test("a Windows path with spaces round-trips through the whole command line", () => { + // The case that matters in practice: the interpreter of a managed environment + // under a user profile whose name has a space in it. + const argv = ["C:\\Users\\A B\\.cache\\openscience\\envs\\p\\default\\Scripts\\python.exe", "-u", "C:\\w\\k.py"] + const line = AppContainer.commandLine(argv) + expect(line).toContain('"C:\\Users\\A B\\') + // Re-split the way CommandLineToArgvW would, to prove the quoting is not + // merely plausible. This mirrors the documented algorithm. + const parsed: string[] = [] + let current = "" + let quoted = false + let slashes = 0 + const flush = () => { + if (current || quoted) parsed.push(current) + current = "" + } + for (const ch of line) { + if (ch === "\\") { + slashes++ + continue + } + if (ch === '"') { + current += "\\".repeat(Math.floor(slashes / 2)) + if (slashes % 2) current += '"' + else quoted = !quoted + slashes = 0 + continue + } + current += "\\".repeat(slashes) + slashes = 0 + if (ch === " " && !quoted) { + flush() + continue + } + current += ch + } + current += "\\".repeat(slashes) + flush() + expect(parsed).toEqual(argv) +}) + +test("the launcher refuses to run anywhere but Windows", () => { + // Guards against a Linux caller reaching FFI that would dlopen kernel32. + if (process.platform === "win32") return + expect(() => AppContainer.launch("S-1-15-2-1", ["x"])).toThrow("only runs on Windows") +}) + +test("the entry point is wired before anything else parses argv", async () => { + // Same rule the egress shim follows: the launcher must not run startup + // middleware, which may fetch over a network the container cannot reach. + const source = await Bun.file(new URL("../../src/index.ts", import.meta.url).pathname).text() + const at = source.indexOf('process.argv[2] === "__appcontainer-launch"') + expect(at).toBeGreaterThan(-1) + expect(at).toBeLessThan(source.indexOf('process.on("unhandledRejection"')) +}) diff --git a/backend/cli/test/sandbox/egress-live-seatbelt.test.ts b/backend/cli/test/sandbox/egress-live-seatbelt.test.ts new file mode 100644 index 00000000..36914c5d --- /dev/null +++ b/backend/cli/test/sandbox/egress-live-seatbelt.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, test } from "bun:test" +import crypto from "crypto" +import { Egress } from "../../src/sandbox/egress" +import { Sandbox } from "../../src/sandbox/sandbox" +import { tmpdir } from "../fixture/fixture" + +/** + * The seatbelt counterpart to egress-live.test.ts: a real `sandbox-exec`, a + * real TCP-loopback `Egress.serveProxy`, and a real remote host, wired + * together exactly the way `Sandbox.plan` composes them in production. Task + * 7 (see `.superpowers/sdd/2026-08-09-sandbox-network-policy/task-7-report.md`) + * built the seatbelt profile and the authenticated loopback proxy entirely + * from Linux, with `platform: "darwin"` injected on every assertion — nobody + * on the project has a Mac, so none of it had ever reached a real + * `sandbox-exec`. This file is what runs when a Mac finally does. + * + * `platform` is deliberately never passed to `Sandbox.plan` below. Every + * seatbelt-specific test elsewhere in `test/sandbox/` injects `"darwin"` to + * exercise the branch from Linux; this file's whole reason to exist is to + * let `Sandbox.backend()`/`decide()` resolve for real, from a real + * `Bun.which("sandbox-exec")` probe, on a machine where that probe can + * actually succeed. + * + * Two open questions from the Task 7 report are what a red run here would + * mean, and this file is written so a reader can tell which: + * + * 1. Whether `network-bind`/`network-inbound` are needed at all for the + * implicit local bind a TCP `connect()` performs, or whether + * `(deny network*)` blocks it regardless of the three narrow allows — + * in which case the sandboxed process never reaches the proxy at all. + * A failure here shows up as the FIRST test below failing to reach + * "200" for the allowlisted host (the process can't dial the proxy + * port in the first place), typically with curl reporting a connection + * error in `stderr` rather than any HTTP status. + * 2. Whether the filter spelling seatbeltProfile emits — `(remote tcp + * "localhost:PORT")` — is what a real `sandbox-exec` expects, versus + * the `(remote ip ...)` this function used before Task 7's fix round 1. + * A failure here shows up the same way as (1) — a wrong filter keyword + * either fails `sandbox-exec -p` outright (a syntax/parse error in + * `stderr`, non-zero exit before the script's own commands ever run) + * or silently fails to match any traffic, which reads identically to + * (1) from this test's vantage point. Either way "the proxy is + * unreachable" is the shared symptom; telling the two apart needs a + * human reading `stderr` for a `sandbox-exec` parse error specifically + * — present means (2), absent means (1) or a genuine enforcement gap. + * + * Everything downstream of that — denied host refused, direct egress with + * the proxy env unset failing, DNS resolving nothing inside the sandbox, + * and volume surviving byte-for-byte through the seatbelt-side proxy path — + * is new coverage of its own kind, not a restatement of the Linux file: + * `Egress.serveProxy`'s TCP/authenticated branch (used only by seatbelt) has + * never taken a live client through a real OS network boundary before this. + * + * Gated on `Sandbox.backend() === "seatbelt"`, real and non-injected — this + * skips on Linux (where it stays exercised by the darwin-injected unit tests + * elsewhere in this directory) and runs, unskipped, on the one machine that + * can: a broken profile on that machine must fail this test, not quietly + * skip it. + */ + +const curl = Bun.which("curl") +const python = Bun.which("python3") + +/** + * A direct, un-sandboxed HEAD against a host the shipped default allowlist + * already permits, run once at collection time — same purpose as + * egress-live.test.ts's `reachable()`: without it, a macOS runner with no + * route to the internet would see the checks below fail exactly the way a + * broken profile would, which is not the defect this file exists to catch. + */ +function reachable() { + if (!curl) return false + const probe = Bun.spawnSync([ + curl, + "-sS", + "-I", + "-m", + "5", + "-o", + "/dev/null", + "-w", + "%{http_code}", + "https://pypi.org/simple/", + ]) + return probe.exitCode === 0 && probe.stdout.toString().trim() === "200" +} + +const skip = Sandbox.backend() !== "seatbelt" || !curl || !python || !reachable() + +/** A real host-side allowlist proxy on an OS-assigned loopback port, fed the + * real shipped `DEFAULT_RULES` — same shape as egress-live.test.ts's + * `proxy()`, but the seatbelt/TCP overload of `Egress.serveProxy` (a fresh + * `crypto.randomUUID()` secret per call, matching what `EgressRuntime`'s + * `startSeatbelt` does for a real proxy start) rather than a unix socket. */ +function proxy(rules: Egress.Rule[]) { + const secret = crypto.randomUUID() + const server = Egress.serveProxy({ hostname: "127.0.0.1", port: 0, secret, rules }) + return { + port: server.port, + secret, + stop: () => server.stop(true), + } +} + +/** Run `script` the way a sandboxed shell command actually runs in + * production — through `Sandbox.plan`, a real `sandbox-exec`-wrapped shell, + * and the real per-connection proxy allowlist check. `egress` is + * `":"`, the exact shape `EgressRuntime.egressFor` produces + * for seatbelt; `buildPolicy` splits it back into `Policy.port`/ + * `Policy.secret`. No `platform` override — see the file doc comment. */ +async function run(script: string, work: string, egress: string) { + const spec = Sandbox.plan({ + command: script, + shell: "/bin/sh", + cwd: work, + workspace: [work], + options: { enabled: true, network: "allowlist", egress }, + }) + const proc = Bun.spawn([spec.file, ...(spec.args ?? [])], { + cwd: work, + env: { ...process.env, ...spec.env }, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]) + await proc.exited + return { stdout, stderr } +} + +describe.skipIf(skip)("egress: a real seatbelt sandbox, a real proxy, a real remote host", () => { + test("an allowlisted host reaches 200, a denied one does not, and neither a direct connection nor DNS works outside the shim", async () => { + await using work = await tmpdir() + const host = proxy(Egress.DEFAULT_RULES) + try { + const egress = `${host.port}:${host.secret}` + const script = [ + `pypi=$(curl -sS -I -m 30 -o /dev/null -w '%{http_code}' https://pypi.org/simple/)`, + `eutils=$(curl -sS -m 30 -o /dev/null -w '%{http_code}' 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/einfo.fcgi')`, + `example=$(curl -sS -I -m 15 -o /dev/null -w '%{http_code}' https://example.com/)`, + // Same load-bearing pair as egress-live.test.ts, and the same + // reasoning: `unset` inside a subshell strips every proxy var for + // this one curl only, so a 200 here would mean the loopback port is + // a convenience rather than the only way out. + `direct=$(unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy ALL_PROXY all_proxy NO_PROXY no_proxy; curl -sS -I -m 10 -o /dev/null -w '%{http_code}' https://pypi.org/)`, + // No `getent` on macOS. `python3 -c` resolves the same host and this + // captures only its exit status: 0 if `gethostbyname` returned an + // address (DNS worked, which it must not, inside the sandbox), a + // Python traceback's exit code (1, unhandled `socket.gaierror`) + // otherwise. Output is discarded either way — only the exit code + // is load-bearing, so nothing here depends on Python's traceback + // format. + `dns=$(python3 -c "import socket; socket.gethostbyname('pypi.org')" >/dev/null 2>&1; printf '%s' "$?")`, + `printf 'PYPI=%s\\nEUTILS=%s\\nEXAMPLE=%s\\nDIRECT=%s\\nDNS=%s\\n' "$pypi" "$eutils" "$example" "$direct" "$dns"`, + ].join("\n") + + const { stdout, stderr } = await run(script, work.path, egress) + const field = (name: string) => stdout.match(new RegExp(`^${name}=(.*)$`, "m"))?.[1] + const detail = `stdout=${stdout} stderr=${stderr}` + + // Guarded with a shape check first on every field, matching + // egress-live.test.ts's convention: without it, a parsing regression + // that made `field()` return undefined would still satisfy + // `not.toBe(...)` and pass with nothing actually verified. + expect(field("PYPI"), detail).toBe("200") + expect(field("EUTILS"), detail).toBe("200") + expect(field("EXAMPLE"), detail).toMatch(/^\d{3}$/) + expect(field("EXAMPLE"), detail).not.toBe("200") + expect(field("DIRECT"), detail).toMatch(/^\d{3}$/) + expect(field("DIRECT"), detail).not.toBe("200") + expect(field("DNS"), detail).toMatch(/^\d+$/) + expect(field("DNS"), detail).not.toBe("0") + } finally { + host.stop() + } + }, 120_000) + + // Same file, same size, same hash as egress-live.test.ts's wheel test — + // deliberately not re-derived, so a divergence between the two backends' + // handling of the exact same bytes would show up as one green and one red + // rather than two different payloads that happen to both pass. `pump` + // (egress.ts) is shared code between the unix-socket and TCP/loopback + // listeners; this is the first time its TCP branch has moved anything + // this large through a real OS network boundary rather than a stubbed one. + const WHEEL_URL = + "https://files.pythonhosted.org/packages/3a/d0/edc009c27b406c4f9cbc79274d6e46d634d139075492ad055e3d68445925/numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" + const WHEEL_SIZE = 18_252_005 + const WHEEL_SHA256 = "666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5" + + test("megabytes through a real proxy and a real host arrive byte-for-byte", async () => { + await using work = await tmpdir() + const host = proxy(Egress.DEFAULT_RULES) + try { + const egress = `${host.port}:${host.secret}` + const out = `${work.path}/numpy.whl` + // Same budget reasoning as egress-live.test.ts: curl's own timeout + // stays comfortably inside the outer 120_000ms so a genuinely slow + // download fails with a legible `curl: (28)` rather than racing the + // outer bun:test timeout and losing the diagnostic. + const script = `curl -sS -m 90 -o ${JSON.stringify(out)} -w '%{http_code} %{size_download}' ${JSON.stringify(WHEEL_URL)}` + + const { stdout, stderr } = await run(script, work.path, egress) + expect(stdout.trim(), `stderr=${stderr}`).toBe(`200 ${WHEEL_SIZE}`) + + const bytes = await Bun.file(out).arrayBuffer() + expect(bytes.byteLength).toBe(WHEEL_SIZE) + expect(new Bun.CryptoHasher("sha256").update(bytes).digest("hex")).toBe(WHEEL_SHA256) + } finally { + host.stop() + } + }, 120_000) + + // The seatbelt counterpart to egress-live.test.ts's pip test, and the + // macOS half of the merge gate: `pip install` under `network: "allowlist"` + // must work on every platform we ship, not just the one it was developed + // on. Deliberately the same package and the same flags as the Linux file, + // for the same reason the wheel test shares its URL and hash — a + // divergence between the two backends should surface as one green and one + // red, not as two different scenarios that happen to both pass. + // + // One thing this covers that the Linux file cannot: pip authenticating to + // the proxy. Seatbelt's loopback port is reachable by every process on the + // machine, so `Sandbox.plan` puts a per-start secret in the proxy URL + // (`http://os:@127.0.0.1:`) and the proxy 407s anything + // without it. curl and urllib are already covered; pip reaches the proxy + // through urllib3, whose own `Proxy-Authorization` handling on CONNECT is + // exercised here for the first time. + test("pip install reaches pypi through the authenticated proxy and the installed package imports", async () => { + await using work = await tmpdir() + const host = proxy(Egress.DEFAULT_RULES) + try { + const egress = `${host.port}:${host.secret}` + const venv = `${work.path}/venv` + const script = [ + `set -e`, + `python3 -m venv ${JSON.stringify(venv)}`, + `${JSON.stringify(`${venv}/bin/pip`)} install --only-binary :all: --disable-pip-version-check -q tqdm`, + `printf 'TQDM=%s\\n' "$(${JSON.stringify(`${venv}/bin/python`)} -c 'import tqdm; print(tqdm.__version__)')"`, + ].join("\n") + + const { stdout, stderr } = await run(script, work.path, egress) + expect(stdout.match(/^TQDM=(.+)$/m)?.[1], `stdout=${stdout} stderr=${stderr}`).toMatch(/^\d+\.\d+/) + } finally { + host.stop() + } + }, 240_000) +}) diff --git a/backend/cli/test/sandbox/egress-live.test.ts b/backend/cli/test/sandbox/egress-live.test.ts new file mode 100644 index 00000000..a8aabf67 --- /dev/null +++ b/backend/cli/test/sandbox/egress-live.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs" +import os from "os" +import path from "path" +import { Egress } from "../../src/sandbox/egress" +import { Sandbox } from "../../src/sandbox/sandbox" +import { tmpdir } from "../fixture/fixture" + +/** + * The composition nobody had committed: a real `bwrap --unshare-net`, a + * real `Egress.serveProxy` on the host, and a real remote host, wired + * together exactly the way `Sandbox.plan`/`wrapArgv` wire them in + * production. Every live test elsewhere in `test/sandbox/` stops short of + * this — `sandbox.test.ts`'s shim tests terminate at a stub `Bun.listen` + * standing in for the proxy, and `egress.test.ts`'s volume tests dial the + * proxy directly, never through a sandboxed process. Neither proves a + * sandboxed command can actually reach pypi.org, and that gap is exactly + * why the proxy shipped silently truncating every transfer above a few KB + * for four review rounds: every test that pushed a few bytes through + * passed. + * + * Two things this file asserts and nothing else does: + * - the socket is the ONLY route out (a denied host gets refused by the + * proxy, AND a direct connection with the proxy variables unset fails, + * AND DNS itself resolves nothing inside the namespace) — without that + * trio this would prove the proxy works, not that it is the only way + * out, which is the actual security claim + * - real volume survives byte-for-byte, not just "curl exited 0" + */ + +const curl = Bun.which("curl") +const python = Bun.which("python3") + +/** + * A direct, un-sandboxed HEAD against a host the shipped default allowlist + * already permits, run once at collection time. Without this, a machine + * with no route to the internet would see the PYPI/EUTILS checks below come + * back non-200 — which reads exactly like the policy defect this file + * exists to catch, when it is really just an unplugged network. Synchronous + * because bun:test needs the skip condition before any test body runs. + */ +function reachable() { + if (!curl) return false + const probe = Bun.spawnSync([ + curl, + "-sS", + "-I", + "-m", + "5", + "-o", + "/dev/null", + "-w", + "%{http_code}", + "https://pypi.org/simple/", + ]) + return probe.exitCode === 0 && probe.stdout.toString().trim() === "200" +} + +// bubblewrap, curl, getent and timeout are all load-bearing below — getent +// proves DNS resolves nothing inside the namespace, timeout bounds it in +// case that ever changes. Absent any of them, or with no network, this +// skips rather than fails: a red run here should mean the egress boundary +// broke, not that the host running the suite is a Mac or is offline. +const skip = + Sandbox.backend() !== "bubblewrap" || !curl || !Bun.which("getent") || !Bun.which("timeout") || !reachable() + +/** A real host-side allowlist proxy on a scratch unix socket — same shape + * as egress.test.ts's `proxy()`, but fed the real shipped `DEFAULT_RULES` + * rather than a synthetic rule, so pypi.org and the NCBI eutils subdomain + * are allowed and example.com is not, exactly as they are for a real user. */ +function proxy(rules: Egress.Rule[]) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "egress-live-")) + const socket = path.join(dir, "e.sock") + const server = Egress.serveProxy({ socket, rules }) + return { + socket, + stop: () => { + server.stop(true) + fs.rmSync(dir, { recursive: true, force: true }) + }, + } +} + +/** Run `script` the way a sandboxed shell command actually runs in + * production — through `Sandbox.plan`, a real `bwrap --unshare-net`, the + * real composed shim script, and the real per-connection proxy allowlist + * check. Nothing here is stubbed. */ +async function run(script: string, work: string, socket: string) { + const spec = Sandbox.plan({ + command: script, + shell: "/bin/sh", + cwd: work, + workspace: [work], + options: { enabled: true, network: "allowlist", egress: socket }, + }) + const proc = Bun.spawn([spec.file, ...(spec.args ?? [])], { + cwd: work, + env: { ...process.env, ...spec.env }, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]) + await proc.exited + return { stdout, stderr } +} + +describe.skipIf(skip)("egress: a real sandbox, a real proxy, a real remote host", () => { + test("an allowlisted host reaches 200, a denied one does not, and neither a direct connection nor DNS works outside the shim", async () => { + await using work = await tmpdir() + const host = proxy(Egress.DEFAULT_RULES) + try { + const script = [ + `pypi=$(curl -sS -I -m 30 -o /dev/null -w '%{http_code}' https://pypi.org/simple/)`, + `eutils=$(curl -sS -m 30 -o /dev/null -w '%{http_code}' 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/einfo.fcgi')`, + `example=$(curl -sS -I -m 15 -o /dev/null -w '%{http_code}' https://example.com/)`, + // The load-bearing pair. `unset` inside a subshell strips every proxy + // var — including `ALL_PROXY`, curl's protocol-agnostic fallback, + // which a host could export even with `HTTPS_PROXY` unset — for this + // one curl only; the checks above still route through the shim. So a + // 200 here would mean the socket is a convenience rather than the + // only way out, which is the entire claim this file exists to prove. + `direct=$(unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy ALL_PROXY all_proxy NO_PROXY no_proxy; curl -sS -I -m 10 -o /dev/null -w '%{http_code}' https://pypi.org/)`, + `resolved=$(timeout 10 getent hosts pypi.org)`, + `printf 'PYPI=%s\\nEUTILS=%s\\nEXAMPLE=%s\\nDIRECT=%s\\nGETENT=[%s]\\n' "$pypi" "$eutils" "$example" "$direct" "$resolved"`, + ].join("\n") + + const { stdout, stderr } = await run(script, work.path, host.socket) + const field = (name: string) => stdout.match(new RegExp(`^${name}=(.*)$`, "m"))?.[1] + const detail = `stdout=${stdout} stderr=${stderr}` + + expect(field("PYPI"), detail).toBe("200") + expect(field("EUTILS"), detail).toBe("200") + // Guarded with a shape check first: without it, a parsing regression + // that made `field()` return undefined would still satisfy + // `not.toBe("200")` and pass with nothing actually verified. + expect(field("EXAMPLE"), detail).toMatch(/^\d{3}$/) + expect(field("EXAMPLE"), detail).not.toBe("200") + expect(field("DIRECT"), detail).toMatch(/^\d{3}$/) + expect(field("DIRECT"), detail).not.toBe("200") + expect(field("GETENT"), detail).toBe("[]") + } finally { + host.stop() + } + }, 120_000) + + // Not pypi.org/simple/ itself: that index's byte length changes as + // packages are published, so only a content-addressed release file has a + // size and sha256 that stay true forever. This is numpy 1.26.4's + // manylinux cp311 wheel from files.pythonhosted.org — re-hashed directly + // against pypi.org while writing this test — chosen for size (18 MB, + // comfortably past the send-buffer boundary where the historical + // truncation bug was invisible) over the alternative of re-fetching + // pypi.org/simple/ (~45 MB) and asserting only its size, which drifts. + const WHEEL_URL = + "https://files.pythonhosted.org/packages/3a/d0/edc009c27b406c4f9cbc79274d6e46d634d139075492ad055e3d68445925/numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" + const WHEEL_SIZE = 18_252_005 + const WHEEL_SHA256 = "666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5" + + test("megabytes through a real proxy and a real host arrive byte-for-byte", async () => { + await using work = await tmpdir() + const host = proxy(Egress.DEFAULT_RULES) + try { + const out = path.join(work.path, "numpy.whl") + // curl's own budget stays comfortably inside the outer 120_000ms: the + // bwrap spawn and shim-readiness wait run before curl even starts, and + // `finally`'s cleanup runs after it ends. Matching the two would let a + // download that genuinely needs close to 120s race the outer bun:test + // timeout instead of curl's own — trading a diagnostic + // `curl: (28) Operation timed out` for a generic "test timed out" and + // deferring `host.stop()` until the abandoned promise chain resolves. + const script = `curl -sS -m 90 -o ${JSON.stringify(out)} -w '%{http_code} %{size_download}' ${JSON.stringify(WHEEL_URL)}` + + const { stdout, stderr } = await run(script, work.path, host.socket) + expect(stdout.trim(), `stderr=${stderr}`).toBe(`200 ${WHEEL_SIZE}`) + + // The status code and curl's own byte count are what a truncation + // bug can still get right — the response frame ends early but + // cleanly. The independent check is reading the file back and + // hashing what actually landed on disk. + const bytes = await Bun.file(out).arrayBuffer() + expect(bytes.byteLength).toBe(WHEEL_SIZE) + expect(new Bun.CryptoHasher("sha256").update(bytes).digest("hex")).toBe(WHEEL_SHA256) + } finally { + host.stop() + } + }, 120_000) + + // The case this whole branch exists to enable: a real `pip install` from + // pypi, inside the sandbox, with `network: "allowlist"` as the only route + // out. The tests above prove the boundary holds; this proves the boundary + // is *usable*, which is a different claim. pip does more than one curl + // does — it issues its own index request, follows a redirect from pypi.org + // to files.pythonhosted.org (a second allowlist entry, so this also covers + // a cross-host hop through the proxy), streams a wheel, and unpacks it. + // + // `--only-binary :all:` keeps this a network test rather than a toolchain + // test: a source build failing for want of a compiler would be a red run + // that says nothing about egress. tqdm is pure Python, small, and pulls no + // dependencies, so a version printed back means the index request, the + // download and the install all crossed the proxy. + // + // The venv itself needs no network — `python3 -m venv` bootstraps pip from + // the wheel bundled in the interpreter's own `ensurepip`. That is why this + // works on a machine with no pip on PATH at all, which was one of the + // three blockers that started this work. + test.skipIf(!python)( + "pip install reaches pypi through the proxy and the installed package imports", + async () => { + await using work = await tmpdir() + const host = proxy(Egress.DEFAULT_RULES) + try { + const venv = path.join(work.path, "venv") + const script = [ + `set -e`, + `python3 -m venv ${JSON.stringify(venv)}`, + `${JSON.stringify(path.join(venv, "bin/pip"))} install --only-binary :all: --disable-pip-version-check -q tqdm`, + `printf 'TQDM=%s\\n' "$(${JSON.stringify(path.join(venv, "bin/python"))} -c 'import tqdm; print(tqdm.__version__)')"`, + ].join("\n") + + const { stdout, stderr } = await run(script, work.path, host.socket) + expect(stdout.match(/^TQDM=(.+)$/m)?.[1], `stdout=${stdout} stderr=${stderr}`).toMatch(/^\d+\.\d+/) + } finally { + host.stop() + } + }, + 240_000, + ) +}) diff --git a/backend/cli/test/sandbox/egress-runtime.test.ts b/backend/cli/test/sandbox/egress-runtime.test.ts new file mode 100644 index 00000000..a8b512ba --- /dev/null +++ b/backend/cli/test/sandbox/egress-runtime.test.ts @@ -0,0 +1,483 @@ +import { afterEach, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { Config } from "../../src/config/config" +import { Global } from "../../src/global" +import { EgressRuntime } from "../../src/sandbox/egress-runtime" +import { Sandbox } from "../../src/sandbox/sandbox" + +// A global config write is process-wide and outlives any one test, so every +// test that touches sandbox config must undo it — otherwise it leaks into +// whichever test file bun happens to run next in this process. +async function cleanGlobalSandboxConfig() { + for (const name of ["openscience.jsonc", "openscience.json", "config.json"]) { + await fs.rm(path.join(Global.Path.config, name), { force: true }).catch(() => {}) + } + Config.global.reset() +} + +afterEach(async () => { + await EgressRuntime.stop() + await cleanGlobalSandboxConfig() +}) + +test("ensure is idempotent and returns a stable address", async () => { + const first = await EgressRuntime.ensure() + const second = await EgressRuntime.ensure() + expect(second.socket).toBe(first.socket) + expect(second.port).toBe(first.port) + await EgressRuntime.stop() +}) + +test("a failed start does not latch — the next call really retries", async () => { + // Making the state directory unwritable is the cheapest real way to make + // the bind fail; every other route (a port already taken, a path too long) + // is either not applicable to a unix socket or harder to arrange + // deterministically. Global.Path.state is a per-test-process tmpdir (see + // test/preload.ts), so this cannot touch a developer's real state dir — + // but it is still restored in `finally`, because leaving it read-only + // would break every later test in this process rather than just this one. + const dir = Global.Path.state + await fs.mkdir(dir, { recursive: true }) + const mode = (await fs.stat(dir)).mode & 0o7777 + + const failure = await (async () => { + try { + await fs.chmod(dir, 0o500) + // platform "linux", not the ambient one: an unwritable state directory + // only fails the bubblewrap listener, which is the one that binds a + // unix socket there. The darwin listener binds a loopback port and + // would have started happily, leaving `failure` undefined. + return await EgressRuntime.ensure("linux").then( + () => undefined, + (error) => error as Error, + ) + } finally { + await fs.chmod(dir, mode) + } + })() + + // Loud: the message has to name the thing that broke and what depends on + // it, since the caller is an unrelated-looking bash/kernel/job spawn. + expect(failure?.message).toContain("sandbox allowlist proxy") + + // Recoverable: the rejected promise must not have been cached. Caching it + // would make one transient failure permanent for the process — and under + // the "allowlist" default that is every bash command, terminal, kernel and + // compute job failing until restart. + const recovered = await EgressRuntime.ensure("linux") + // Non-null: the bubblewrap listener always carries a socket — + // `egress-runtime.ts`'s `Running` type makes the field optional only + // because the darwin branch (added by Task 7) carries a TCP endpoint + // instead. + await expect(fs.stat(recovered.socket!)).resolves.toBeDefined() +}) + +test("stop is safe after a start that failed", async () => { + const dir = Global.Path.state + await fs.mkdir(dir, { recursive: true }) + const mode = (await fs.stat(dir)).mode & 0o7777 + try { + await fs.chmod(dir, 0o500) + // platform "linux": same reason as the test above — only the bubblewrap + // listener fails on an unwritable state directory, and a start that + // succeeded would not exercise the escape hatch this test is about. + await EgressRuntime.ensure("linux").catch(() => {}) + } finally { + await fs.chmod(dir, mode) + } + // The escape hatch must not hand back the same failure it exists to clear. + await expect(EgressRuntime.stop()).resolves.toBeUndefined() +}) + +test("the socket is created under the state directory, not the workspace", async () => { + // platform "linux": there is no socket to place at all on darwin, which + // listens on a loopback port instead. + const { socket } = await EgressRuntime.ensure("linux") + // Non-null: the bubblewrap listener always carries a socket. + // Not Bun.file(socket).exists(): Bun.file() only recognizes regular files, + // and a unix socket is a distinct inode type (S_IFSOCK) — verified with an + // isolated Bun.listen({ unix }) that Bun.file(...).exists() reports false + // for it while fs.stat sees it fine. fs.stat is the correct check here. + await expect(fs.stat(socket!)).resolves.toBeDefined() + expect(socket).not.toContain(process.cwd()) + await EgressRuntime.stop() +}) + +/** Speaks the proxy's wire format directly (see egress.ts) rather than going + * through the sandbox/shim, so this stays a test of EgressRuntime's rule + * freshness and not of the loopback bridge. CONNECT is used because its + * authority is the raw request target — no URL parsing to get right — and + * because both outcomes under test (denied vs. attempted-and-unreachable) + * answer with a 403 whose body text is the only thing distinguishing them. */ +function proxyRequest(socket: string, authority: string): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(`no response from the proxy for ${authority}`)), 2_000) + let body = "" + Bun.connect({ + unix: socket, + socket: { + open(client) { + client.write(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n\r\n`) + }, + data(_client, chunk) { + body += chunk.toString() + }, + close() { + clearTimeout(timeout) + resolve(body) + }, + error(_client, error) { + clearTimeout(timeout) + reject(error) + }, + }, + }).catch(reject) + }) +} + +test("an allowlist edit reaches a running proxy without restarting it", async () => { + // Nothing listens on loopback:1 (a privileged, essentially never-bound + // port), so a request that clears the allowlist check still gets a 403 — + // "cannot reach", not "not on the allowlist". That distinction is what + // proves the check ran, without needing a real upstream. + const authority = "127.0.0.1:1" + // platform "linux": `proxyRequest` speaks to a unix socket, which only the + // bubblewrap listener has. The freshness behaviour under test is the + // proxy's, not the listener's, so pinning the transport keeps this one + // test meaningful on either kind of machine. + const first = await EgressRuntime.ensure("linux") + + // Non-null: the bubblewrap listener always carries a socket. + const before = await proxyRequest(first.socket!, authority) + expect(before).toContain("not on the sandbox allowlist") + + await Config.setSandbox({ allowHosts: ["127.0.0.1"] }) + + // The proxy was never restarted — the same server, on the same socket, + // now answers differently because it re-reads the allowlist per + // connection rather than the snapshot it was born with. Retried rather + // than asserted on the first attempt: the update reaches the running + // proxy through a reactive config-change listener, not synchronously + // with Config.setSandbox's own return. + const deadline = Date.now() + 2_000 + let after = before + while (Date.now() < deadline && after.includes("not on the sandbox allowlist")) { + after = await proxyRequest(first.socket!, authority) + } + expect(after).not.toContain("not on the sandbox allowlist") + expect(after).toContain("Cannot reach") + + const second = await EgressRuntime.ensure("linux") + expect(second.socket).toBe(first.socket) // same proxy the whole time, not a restart +}) + +/** + * `egressFor` has to answer, ahead of time, the same "would this actually be + * sandboxed with an allowlist" question that `Sandbox.plan()`/`wrapArgv()` + * answer for real via `decide()` + `buildPolicy()` — its socket becomes their + * `options.egress`. The two used to default the unset cases in opposite + * directions from each other on both fields, invisibly, because production's + * five callers always pass an already-fully-resolved policy. These pin the + * shared default (`Sandbox.resolved`) so a future edit that reintroduces a + * hand-rolled check in just one of the two places fails here instead of + * shipping. + */ +test("egressFor treats a missing enabled the same way decide() does: off", async () => { + // Old behaviour: only an explicit `enabled: false` opted out, so this + // started a real proxy nothing could ever reach — decide() never wraps a + // command whose `options.enabled` isn't literally `true`. + const egress = await EgressRuntime.egressFor({ network: "allowlist" }) + expect(egress).toBeUndefined() +}) + +test.skipIf(Sandbox.backend() !== "bubblewrap")( + "egressFor treats a missing network the same way buildPolicy() does: allowlist", + async () => { + // Old behaviour: a missing `network` read as "not allowlist" here, so + // this returned undefined while buildPolicy() (used by the same + // options a moment later, in Sandbox.plan/wrapArgv) still defaulted + // network to "allowlist" and demanded an egress socket — the exact + // "requires an egress socket path" crash, reproduced below without the + // fix. + const egress = await EgressRuntime.egressFor({ enabled: true }) + expect(egress).toBeDefined() + expect(() => + Sandbox.plan({ + command: "true", + shell: "/bin/sh", + cwd: "/tmp", + workspace: ["/tmp"], + options: { enabled: true, egress }, + }), + ).not.toThrow() + }, +) + +/** + * Same wire technique as `proxyRequest` above, but dialed over TCP loopback + * rather than the unix socket — what a seatbelt-sandboxed process reaches + * directly, since seatbelt has no namespace to bind a unix socket into and + * `Egress.serveProxy` listens on that loopback port itself (decision 1 of + * the Task 7 brief: no host-side bridge). `auth`, when given, is sent as the + * `Proxy-Authorization` secret the TCP listener requires (decision 2) — + * omitted or wrong, the request must never reach the allowlist check at all. + * + * This is the one seatbelt-specific piece of Task 7 that genuinely runs, + * without a Mac: everything here is a plain `Bun.connect`/`Bun.listen` pair + * with nothing namespace- or platform-specific about it, so starting + * `EgressRuntime` with `platform: "darwin"` injected and dialing it for real + * proves the proxy → auth → allowlist path actually runs. What it cannot + * prove is whether a real `sandbox-exec` restricts a sandboxed process to + * dialing only this one port in the first place — see the Task 7 report for + * exactly what a Mac owner still needs to run. + */ +function tcpProxyRequest(port: number, authority: string, auth?: string): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(`no response from the proxy for ${authority}`)), 2_000) + let body = "" + const header = auth ? `\r\nProxy-Authorization: Basic ${Buffer.from(`os:${auth}`).toString("base64")}` : "" + Bun.connect({ + hostname: "127.0.0.1", + port, + socket: { + open(client) { + client.write(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}${header}\r\n\r\n`) + }, + data(_client, chunk) { + body += chunk.toString() + }, + close() { + clearTimeout(timeout) + resolve(body) + }, + error(_client, error) { + clearTimeout(timeout) + reject(error) + }, + }, + }).catch(reject) + }) +} + +test("ensure with platform darwin listens on a loopback TCP port, not a unix socket", async () => { + const running = await EgressRuntime.ensure("darwin") + expect(running.hostname).toBe("127.0.0.1") + // Ephemeral, not the bwrap shim's fixed SHIM_PORT: seatbelt has no + // namespace to keep a fixed port private across concurrently sandboxed + // processes the way --unshare-net does for bubblewrap. + expect(running.port).not.toBe(Sandbox.SHIM_PORT) + expect(running.port).toBeGreaterThan(0) + // A secret was generated for this start — required to reach the proxy at + // all, since a loopback port (unlike a unix socket) carries no filesystem + // permissions of its own. + expect(running.secret).toBeTruthy() + // And no unix socket on this path at all — decision 1 of the Task 7 + // brief: serveProxy listens on TCP directly, no host-side bridge to one. + expect(running.socket).toBeUndefined() +}) + +test("the darwin proxy forwards a correctly-authenticated request past both auth and the allowlist check, live", async () => { + // 127.0.0.1 is not in Egress.DEFAULT_RULES, so it has to be added + // explicitly here — otherwise a request to 127.0.0.1:1 is denied at the + // allowlist check before the auth → dial chain this test exists to prove + // is ever reached at all. (Task 7 fix round 1, I2: the unfixed version of + // this test asserted "not on the sandbox allowlist" — the denial text — + // while its own comment claimed the opposite outcome. Both the comment + // and the assertion described a dial that never actually happened; + // measured by the reviewer, confirmed here by fixing it forward instead + // of just correcting the prose.) + await Config.setSandbox({ allowHosts: ["127.0.0.1"] }) + const running = await EgressRuntime.ensure("darwin") + // Nothing listens on loopback:1 (a privileged, essentially never-bound + // port), so a request that clears BOTH the auth check and the allowlist + // check still gets a 403 "Cannot reach" — not "not on the sandbox + // allowlist" and not 407 — which is what proves the whole + // auth → allowlist → dial chain actually ran end to end. + const body = await tcpProxyRequest(running.port, "127.0.0.1:1", running.secret) + expect(body).toContain("Cannot reach") + expect(body).not.toContain("not on the sandbox allowlist") + expect(body).not.toContain("407") +}) + +test("the darwin proxy refuses a request with no Proxy-Authorization, and never forwards it", async () => { + const running = await EgressRuntime.ensure("darwin") + const body = await tcpProxyRequest(running.port, "127.0.0.1:1") + expect(body).toContain("407 Proxy Authentication Required") + // Neither downstream outcome appears — the request was refused before the + // allowlist check or the dial ever ran. + expect(body).not.toContain("not on the sandbox allowlist") + expect(body).not.toContain("Cannot reach") +}) + +test("the darwin proxy refuses a request with the wrong secret", async () => { + const running = await EgressRuntime.ensure("darwin") + const body = await tcpProxyRequest(running.port, "127.0.0.1:1", `${running.secret}-wrong`) + expect(body).toContain("407 Proxy Authentication Required") +}) + +test('egressFor on darwin returns "port:secret", not a socket path', async () => { + const egress = await EgressRuntime.egressFor({ enabled: true, network: "allowlist" }, "darwin") + expect(egress).toBeDefined() + const [portPart, secretPart] = egress!.split(":") + expect(Number.isInteger(Number(portPart))).toBe(true) + // Shape-checked against crypto.randomUUID()'s actual format, not just + // toBeTruthy(): the string "undefined" — what a missing `secret` coerces + // to inside a template literal — is itself truthy, so a bare truthiness + // check structurally cannot catch the I1 defect the next test reproduces. + expect(secretPart).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) + expect(egress).not.toContain("/") + expect(egress).not.toContain(".sock") +}) + +// Task 7 fix round 1, I1: egressFor's seatbelt branch used to interpolate +// `running.secret` with no guard. ensure()/start() cache ONE proxy for the +// process lifetime (see ensure()'s doc comment); `platform` only decides +// what starts when nothing is running yet. Asking for "darwin" after a +// bubblewrap proxy is already cached — impossible for a real caller, since +// process.platform never changes mid-process, but reachable here because +// platform is deliberately injectable for testing — used to silently reuse +// that cached listener and return the literal string "3128:undefined" +// (Buffer-safe, syntactically valid, and — per the test above — exactly +// what a bare `toBeTruthy()` on the secret half cannot distinguish from a +// real one). Confirmed by execution before the fix; asserts the fail-closed +// replacement here. +test("egressFor on darwin fails closed rather than composing an undefined secret when a differently-platformed proxy is already cached", async () => { + // Force the FIRST proxy to be the bubblewrap (unix-socket) shape, + // deterministically regardless of what machine actually runs this test — + // the same platform-injection seam every darwin test in this file uses, + // just pointed at the other platform. + await EgressRuntime.ensure("linux") + await expect(EgressRuntime.egressFor({ enabled: true, network: "allowlist" }, "darwin")).rejects.toThrow( + /already running as the bubblewrap/, + ) +}) + +test("egressFor with network deny or allow never starts a proxy on darwin", async () => { + const deny = await EgressRuntime.egressFor({ enabled: true, network: "deny" }, "darwin") + expect(deny).toBeUndefined() + const allow = await EgressRuntime.egressFor({ enabled: true, network: "allow" }, "darwin") + expect(allow).toBeUndefined() +}) + +test("egressFor on linux/bubblewrap keeps returning the unix socket path, unaffected by the darwin branch", async () => { + if (Sandbox.backend() !== "bubblewrap") return + const egress = await EgressRuntime.egressFor({ enabled: true, network: "allowlist" }) + expect(egress).toContain(".sock") +}) + +/** + * Task 7 fix round 1, I4: every auth test above hand-builds the + * `Proxy-Authorization` header itself, which leaves the seam joining + * `sandbox.ts`'s `proxyUrl()` (`http://os:@host:port`) to + * `egress.ts`'s own parser of that header unpinned in-suite — a rename of + * the userinfo user ("os") in one place only would still pass every other + * test here. These drive a real `curl`, an independent HTTP client + * implementation, at the *exact* URL `Sandbox.plan()` composes, covering + * both wire forms curl uses to talk to a proxy: an absolute-form GET (its + * default for a plain `http://` target) and a CONNECT tunnel (forced with + * `--proxytunnel`, and also what curl uses unprompted for an `https://` + * target — see `egress-live.test.ts` for that shape against a real host). + * A third, with Python's `urllib`, covers a second independent client + * library — the same proxy-auth mechanism pip itself relies on. + */ +function planProxyUrl(egress: string): string { + const plan = Sandbox.plan({ + command: "true", + shell: "/bin/sh", + cwd: "/tmp", + workspace: ["/tmp"], + options: { enabled: true, network: "allowlist", egress }, + platform: "darwin", + }) + const proxy = plan.env?.HTTP_PROXY + expect(proxy).toMatch(/^http:\/\/os:.+@127\.0\.0\.1:\d+$/) + return proxy! +} + +/** `Bun.spawn`, never `Bun.spawnSync`: the origin and the proxy both reply + * from `Bun.listen`/`Bun.serve` callbacks on this same event loop, so a + * *synchronous* spawn would block that loop for as long as the child runs + * — the child blocks on recv() waiting for a reply the loop can't yet + * deliver, deadlocking both sides until the test times out. Reproduced + * while writing these three tests (all three hung at 5s with empty stdout) + * — the same defect class `sandbox.test.ts`'s own "Bun.spawn, not + * spawnSync" comment documents for an identical reason. */ +async function runCapture(cmd: string[]) { + const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" }) + const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]) + await proc.exited + return { stdout, stderr } +} + +test.skipIf(!Bun.which("curl"))( + "a real curl (absolute-form GET) using the exact URL Sandbox.plan() emits authenticates and is forwarded", + async () => { + const origin = Bun.serve({ port: 0, fetch: () => new Response("ok") }) + try { + await Config.setSandbox({ allowHosts: ["127.0.0.1"] }) + const running = await EgressRuntime.ensure("darwin") + const proxy = planProxyUrl(`${running.port}:${running.secret}`) + const { stdout, stderr } = await runCapture([ + "curl", + "-sS", + "-m", + "5", + "-x", + proxy, + `http://127.0.0.1:${origin.port}/`, + ]) + expect(stdout, stderr).toBe("ok") + } finally { + origin.stop(true) + } + }, +) + +test.skipIf(!Bun.which("curl"))( + "a real curl --proxytunnel (forced CONNECT) using the exact URL Sandbox.plan() emits authenticates and is forwarded", + async () => { + const origin = Bun.serve({ port: 0, fetch: () => new Response("ok") }) + try { + await Config.setSandbox({ allowHosts: ["127.0.0.1"] }) + const running = await EgressRuntime.ensure("darwin") + const proxy = planProxyUrl(`${running.port}:${running.secret}`) + const { stdout, stderr } = await runCapture([ + "curl", + "-sS", + "-m", + "5", + "--proxytunnel", + "-x", + proxy, + `http://127.0.0.1:${origin.port}/`, + ]) + expect(stdout, stderr).toBe("ok") + } finally { + origin.stop(true) + } + }, +) + +test.skipIf(!Bun.which("python3"))( + "a real Python urllib request using the exact URL Sandbox.plan() emits authenticates and is forwarded", + async () => { + const origin = Bun.serve({ port: 0, fetch: () => new Response("ok") }) + try { + await Config.setSandbox({ allowHosts: ["127.0.0.1"] }) + const running = await EgressRuntime.ensure("darwin") + const proxy = planProxyUrl(`${running.port}:${running.secret}`) + const target = `http://127.0.0.1:${origin.port}/` + const script = [ + "import urllib.request", + `handler = urllib.request.ProxyHandler({"http": ${JSON.stringify(proxy)}})`, + "opener = urllib.request.build_opener(handler)", + `print(opener.open(${JSON.stringify(target)}, timeout=5).read().decode(), end="")`, + ].join("\n") + const { stdout, stderr } = await runCapture(["python3", "-c", script]) + expect(stdout, stderr).toBe("ok") + } finally { + origin.stop(true) + } + }, +) diff --git a/backend/cli/test/sandbox/egress.test.ts b/backend/cli/test/sandbox/egress.test.ts new file mode 100644 index 00000000..69eb0e4c --- /dev/null +++ b/backend/cli/test/sandbox/egress.test.ts @@ -0,0 +1,679 @@ +import { afterEach, expect, test } from "bun:test" +import fs from "fs" +import os from "os" +import path from "path" +import type { Socket } from "bun" +import { Egress } from "../../src/sandbox/egress" + +test("an exact rule matches only that host", () => { + expect(Egress.allowed("pypi.org", ["pypi.org"])).toBe(true) + expect(Egress.allowed("evil-pypi.org", ["pypi.org"])).toBe(false) +}) + +test("a leading dot matches the domain and its subdomains", () => { + expect(Egress.allowed("eutils.ncbi.nlm.nih.gov", [".ncbi.nlm.nih.gov"])).toBe(true) + expect(Egress.allowed("ncbi.nlm.nih.gov", [".ncbi.nlm.nih.gov"])).toBe(true) + expect(Egress.allowed("ncbi.nlm.nih.gov.evil.com", [".ncbi.nlm.nih.gov"])).toBe(false) +}) + +test("a port on the authority is ignored when matching", () => { + expect(Egress.allowed("pypi.org:443", ["pypi.org"])).toBe(true) +}) + +test("matching is case-insensitive in both directions", () => { + expect(Egress.allowed("PyPI.ORG", ["pypi.org"])).toBe(true) + expect(Egress.allowed("pypi.org", ["PyPI.ORG"])).toBe(true) +}) + +test("an empty ruleset allows nothing", () => { + expect(Egress.allowed("pypi.org", [])).toBe(false) +}) + +test("the shipped defaults cover the registries and scientific APIs the product needs", () => { + for (const host of [ + "pypi.org", + "files.pythonhosted.org", + "cran.r-project.org", + "eutils.ncbi.nlm.nih.gov", + "rest.uniprot.org", + ]) { + expect(Egress.allowed(host, Egress.DEFAULT_RULES)).toBe(true) + } +}) + +test("the shipped defaults do not permit general browsing", () => { + for (const host of ["example.com", "www.google.com", "raw.githubusercontent.com"]) { + expect(Egress.allowed(host, Egress.DEFAULT_RULES)).toBe(false) + } +}) + +// ── volume ────────────────────────────────────────────────────────────────── +// +// Everything above, and every earlier test of the bridge itself, moves a few +// bytes. That is exactly the size at which a dropped-backpressure bug is +// invisible: one small write fits the send buffer whole, so the byte count +// `Socket.write` returns equals what was asked of it and discarding that count +// costs nothing. Past a send buffer it does not — before `pump` existed, 8 MB +// through the proxy arrived as ~2.6 MB, and `pip download numpy` inside a real +// sandbox died with `SSL: RECORD_LAYER_FAILURE` while an 11 KB package +// installed fine. So these transfer real volume, in both directions, and +// compare the bytes rather than counting them. + +const VOLUME = 8 * 1024 * 1024 + +/** Not a constant fill: a repeated byte would pass even if the bridge + * duplicated or reordered a chunk, which is the other way a backpressure + * queue goes wrong. This makes position observable. */ +const sample = Buffer.from(Uint8Array.from({ length: VOLUME }, (_, i) => (i * 31 + (i >> 13)) % 251)) + +const opened: { stop: () => void }[] = [] + +afterEach(() => { + for (const it of opened.splice(0)) it.stop() +}) + +/** A raw TCP origin. Sends `sample` at whatever pace the peer accepts (so the + * test measures the bridge's backpressure, not the origin's), collects + * everything sent to it, and closes only once both halves are complete — + * which is also what makes an early `end()` on the bridge observable, since a + * close that jumps a queue truncates the tail rather than hanging. */ +type Talker = { sent: number; got: number; head: number; received: Buffer[] } + +function origin() { + const uploads: Buffer[][] = [] + const talkers = new WeakMap, Talker>() + + const push = (sock: Socket, held: Talker) => { + while (held.sent < VOLUME) { + const wrote = sock.write(sample.subarray(held.sent)) + if (wrote <= 0) return + held.sent += wrote + } + if (held.head >= 0 && held.got - held.head >= VOLUME) sock.end() + } + + const server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + open(sock) { + const held: Talker = { sent: 0, got: 0, head: -1, received: [] } + uploads.push(held.received) + talkers.set(sock, held) + }, + data(sock, chunk) { + const held = talkers.get(sock) + if (!held) return + held.received.push(Buffer.from(chunk)) + held.got += chunk.length + // The request head is the cue to start sending, and its length is what + // makes "the whole upload arrived" a byte count rather than a guess. + if (held.head < 0) { + const end = Buffer.concat(held.received).indexOf("\r\n\r\n") + if (end < 0) return + held.head = end + 4 + } + push(sock, held) + }, + drain(sock) { + const held = talkers.get(sock) + if (held) push(sock, held) + }, + error() {}, + }, + }) + + opened.push({ stop: () => server.stop(true) }) + return { port: server.port, uploads } +} + +function proxy(rules: string[]) { + const socket = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "egress-vol-")), "e.sock") + const server = Egress.serveProxy({ socket, rules }) + opened.push({ + stop: () => { + server.stop(true) + fs.rmSync(path.dirname(socket), { recursive: true, force: true }) + }, + }) + return socket +} + +function shim(socket: string) { + // Port 0 lets the OS pick, so concurrent test files cannot collide the way a + // fixed 3128 would. Inside a real sandbox the port is fixed instead, because + // --unshare-net makes collision impossible there. + const server = Egress.serveShim({ port: 0, socket }) + opened.push({ stop: () => server.stop(true) }) + return server.port +} + +/** Speak CONNECT to the proxy, upload `send`, then read until close. */ +function transfer(to: { unix: string } | { hostname: string; port: number }, authority: string, send: Buffer) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("the transfer never completed")), 60_000) + const chunks: Buffer[] = [] + const state = { established: false, at: 0 } + const upload = (sock: Socket) => { + while (state.at < send.length) { + const wrote = sock.write(send.subarray(state.at)) + if (wrote <= 0) return + state.at += wrote + } + } + const done = (result: Buffer) => { + clearTimeout(timeout) + resolve(result) + } + const handlers = { + open(sock: Socket) { + sock.write(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n\r\n`) + }, + data(sock: Socket, chunk: Buffer) { + if (state.established) return void chunks.push(Buffer.from(chunk)) + const end = chunk.indexOf("\r\n\r\n") + if (end === -1) return + state.established = true + chunks.push(Buffer.from(chunk.subarray(end + 4))) + sock.write("GET / HTTP/1.0\r\n\r\n") + upload(sock) + }, + drain: upload, + close: () => done(Buffer.concat(chunks)), + error: () => done(Buffer.concat(chunks)), + } + // Branched rather than spread: Bun.connect is overloaded on unix vs + // hostname/port, and a union spread into one object literal matches + // neither overload. + const dial = + "unix" in to + ? Bun.connect({ unix: to.unix, socket: handlers }) + : Bun.connect({ hostname: to.hostname, port: to.port, socket: handlers }) + dial.catch(reject) + }) +} + +test("megabytes survive the proxy byte for byte, in both directions", async () => { + const upstream = origin() + const socket = proxy(["127.0.0.1"]) + + const down = await transfer({ unix: socket }, `127.0.0.1:${upstream.port}`, sample) + + expect(down.length).toBe(VOLUME) + expect(down.equals(sample)).toBe(true) + // The uploaded copy arrives behind the "GET / HTTP/1.0" that cued the + // download, so drop that prefix before comparing. + const up = Buffer.concat(upstream.uploads[0]!) + const body = up.subarray(up.indexOf("\r\n\r\n") + 4) + expect(body.length).toBe(VOLUME) + expect(body.equals(sample)).toBe(true) +}, 120_000) + +test("megabytes survive the shim and the proxy together, in both directions", async () => { + const upstream = origin() + const socket = proxy(["127.0.0.1"]) + const port = shim(socket) + + const down = await transfer({ hostname: "127.0.0.1", port }, `127.0.0.1:${upstream.port}`, sample) + + expect(down.length).toBe(VOLUME) + expect(down.equals(sample)).toBe(true) + const up = Buffer.concat(upstream.uploads[0]!) + const body = up.subarray(up.indexOf("\r\n\r\n") + 4) + expect(body.length).toBe(VOLUME) + expect(body.equals(sample)).toBe(true) +}, 120_000) + +// ── one client, one upstream ──────────────────────────────────────────────── +// +// Both bridges dial their upstream from inside an `async` handler, and Bun does +// not serialize those handlers — a second chunk, or a client's FIN, re-enters +// while the first call is parked on `await Bun.connect`. Two distinct defects +// live in that window, and neither is visible to a test that moves bytes +// through a connection that behaves politely from start to finish. + +/** Handlers for an upstream that counts connections and, crucially, whether + * each one was ever closed. Counting sockets rather than file descriptors + * keeps this honest on platforms without /proc, and measures the actual + * invariant: nothing a bridge dials may be left with no owner. Each + * connection's bytes accumulate in their own entry, so a concurrent second + * connection cannot have its bytes attributed to the first. */ +function counter() { + const seen: { text: string }[] = [] + const counts = { opened: 0, closed: 0 } + const entries = new WeakMap, { text: string }>() + const socket = { + open(sock: Socket) { + counts.opened++ + const entry = { text: "" } + seen.push(entry) + entries.set(sock, entry) + }, + data(sock: Socket, chunk: Buffer) { + const entry = entries.get(sock) + if (entry) entry.text += chunk.toString() + }, + close() { + counts.closed++ + }, + error() {}, + } + return { counts, seen, socket } +} + +function unixCounter(at: string) { + const held = counter() + const server = Bun.listen({ unix: at, socket: held.socket }) + opened.push({ stop: () => server.stop(true) }) + return held +} + +function tcpCounter() { + const held = counter() + const server = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: held.socket }) + opened.push({ stop: () => server.stop(true) }) + return { ...held, port: server.port } +} + +function scratch(name: string) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "egress-abort-")) + opened.push({ stop: () => fs.rmSync(dir, { recursive: true, force: true }) }) + return path.join(dir, name) +} + +/** Wait for the counts to stop moving rather than guessing at the race. */ +async function settle(counts: { opened: number; closed: number }) { + for (let i = 0; i < 40 && counts.closed < counts.opened; i++) await Bun.sleep(50) +} + +/** connect(), then FIN in the same turn, with nothing sent. */ +function abort(port: number) { + return Bun.connect({ + hostname: "127.0.0.1", + port, + socket: { + open(sock) { + sock.end() + }, + data() {}, + close() {}, + error() {}, + }, + }) +} + +test("a client that aborts mid-dial does not strand the shim's upstream", async () => { + const socket = scratch("u.sock") + const upstream = unixCounter(socket) + const port = shim(socket) + + const N = 60 + for (let i = 0; i < N; i++) await abort(port) + await settle(upstream.counts) + + // The dial really happened — otherwise this would pass trivially. + expect(upstream.counts.opened).toBeGreaterThan(0) + // And every one of them was closed. Before the shim tracked the client's + // departure, `close` ran while `toUpstream` was still undefined, so it had + // nothing to tear down: measured at 300 aborts across separate processes, + // 0.897 fd/conn stranded in the shim and the same in the host proxy, held + // for as long as the sandbox lives — hours, for a kernel or a terminal. + expect(upstream.counts.closed).toBe(upstream.counts.opened) +}, 60_000) + +test("a client that aborts mid-dial does not strand the proxy's upstream", async () => { + const socket = scratch("p.sock") + // "localhost" rather than 127.0.0.1: the dial then includes a name lookup, + // which is what holds the window open long enough to observe any of this. + // Resolved from /etc/hosts, so no network is involved. + const upstream = tcpCounter() + const server = Egress.serveProxy({ socket, rules: ["localhost"] }) + opened.push({ stop: () => server.stop(true) }) + + const N = 40 + for (let i = 0; i < N; i++) { + const client = await Bun.connect({ + unix: socket, + socket: { + open(sock) { + // A complete head, so the proxy commits to dialling, then leave. + sock.write(`CONNECT localhost:${upstream.port} HTTP/1.1\r\nHost: localhost\r\n\r\n`) + sock.end() + }, + data() {}, + close() {}, + error() {}, + }, + }) + client.end() + } + await settle(upstream.counts) + + expect(upstream.counts.opened).toBeGreaterThan(0) + expect(upstream.counts.closed).toBe(upstream.counts.opened) +}, 60_000) + +test("a body that arrives after its head still produces exactly one upstream", async () => { + const pieces = ["id=1", "&id=2", "&x"] + const body = pieces.join("") + const socket = scratch("d.sock") + const upstream = tcpCounter() + const server = Egress.serveProxy({ socket, rules: ["localhost"] }) + opened.push({ stop: () => server.stop(true) }) + + // The shape NCBI E-utilities recommends for a large id list, and the shape + // `HTTP_PROXY` routes through this branch: a plain-http POST whose body + // follows the head across separate segments. Each of those segments used to + // re-enter `data`, find no link yet, re-parse the same buffered head and + // dial again — 2 upstream connections against a local origin, 4 against a + // real remote one, every one of them carrying a duplicate of a + // non-idempotent request. + // Run in parallel, and not as a nod to realism: the dial has to still be in + // flight when the next segment lands, and on loopback with a warm resolver a + // single dial finishes inside the 1ms gap. Concurrency is what holds the + // window open — enough simultaneous name lookups to queue behind the + // resolver — and it is the only trigger measured here that survives a warm + // cache. Against the unfixed proxy this produced 200 upstream connections + // for these 100 clients, three runs out of three; at 25 clients it was one + // run in three, and sequentially it needed a cold cache to reproduce at all. + const target = `localhost:${upstream.port}` + const clients = 100 + const socks = await Promise.all( + Array.from({ length: clients }, async () => { + const client = await Bun.connect({ + unix: socket, + socket: { data() {}, close() {}, error() {} }, + }) + client.write(`POST http://${target}/eutils HTTP/1.1\r\nHost: ${target}\r\nContent-Length: ${body.length}\r\n\r\n`) + for (const [i, gap] of [1, 5, 10].entries()) { + await Bun.sleep(gap) + client.write(pieces[i]!) + } + return client + }), + ) + await Bun.sleep(1_000) + + expect(upstream.counts.opened).toBe(clients) + // And each one carries a whole request, rather than the head being + // duplicated onto a second connection with the body split between them. + expect(upstream.seen.length).toBe(clients) + for (const entry of upstream.seen) { + expect(entry.text).toContain("POST /eutils") + expect(entry.text).toContain(body) + } + for (const s of socks) s.end() +}, 60_000) + +// ── what one client can make the host allocate ────────────────────────────── +// +// serveProxy runs on the HOST, outside the sandbox, and holds memory on behalf +// of a process the sandbox exists to contain — in the CLI's own process, so an +// OOM there is the supervisor dying, not a worker. Two sub-phases buffer +// without a link to push into, and neither was bounded: +// +// head never terminated by CRLFCRLF, so no dial is ever attempted and +// nothing downstream limits it. Measured: 93 MiB of head took the +// host process from 36.0 MB to 1344.9 MB of RSS in 8 s, climbing. +// dialing a complete head for an allowlisted host that black-holes SYNs. +// Measured: 2048.6 MiB blasted in 8 s took it from 36.0 MB to +// 2120.2 MB and then killed it with `RangeError: Out of memory`, +// with the dial still in flight and ~2 minutes of SYN retries left. +// +// Both assertions below are on bytes the proxy was willing to *accept*, which +// is what the growth was made of, and both are orders of magnitude clear of +// the fixed bounds so neither is timing-sensitive in the passing direction. + +/** Blast at a target until it stops accepting or `ms` elapses, and report how + * much it took. `stalled` distinguishes "the proxy stopped reading" — the + * backpressure this is looking for — from "we ran out of time". */ +function flood(to: { unix: string }, head: string, ms: number) { + return new Promise<{ sent: number; response: string; stalled: boolean }>((resolve, reject) => { + const CHUNK = Buffer.alloc(1 << 20, 0x41) // 'A' — cannot contain CRLFCRLF + const state = { sent: 0, response: "", done: false, stalled: false } + const finish = () => { + if (state.done) return + state.done = true + clearTimeout(timer) + resolve({ sent: state.sent, response: state.response, stalled: state.stalled }) + } + const timer = setTimeout(finish, ms) + const push = (sock: Socket) => { + state.stalled = false + while (!state.done) { + const wrote = sock.write(CHUNK) + if (wrote <= 0) return void (state.stalled = true) + state.sent += wrote + } + } + Bun.connect({ + unix: to.unix, + socket: { + open(sock) { + if (head) sock.write(head) + push(sock) + }, + drain: push, + data(_sock, chunk) { + state.response += chunk.toString("latin1") + }, + close: finish, + error: finish, + }, + }).catch(reject) + }) +} + +test("a head that never ends is refused rather than buffered", async () => { + const socket = proxy(["127.0.0.1"]) + + // No CRLFCRLF anywhere in the payload, so the parse never completes and the + // proxy never dials — this phase is not bounded by a connect() at all. + const flooded = await flood({ unix: socket }, "", 4_000) + + // Fail closed, and say why: 431 is the status RFC 6585 defines for exactly + // this. Against the unbounded version the client got no response whatsoever, + // because there is nothing in that code path that ever answers. + expect(flooded.response).toContain("431 Request Header Fields Too Large") + expect(flooded.response).toContain("Proxy request head exceeded") + + // And it was cut off early. The cap is 64 KiB; the client stops at whatever + // was already in flight when the proxy closed, which is a socket buffer or + // two. 16 MiB is a bound no correct implementation approaches and the + // unbounded one blows through in well under a second — it took 93 MiB in 8 s + // while growing the host by 1.3 GB. + expect(flooded.sent).toBeLessThan(16 * 1024 * 1024) +}, 60_000) + +test("a client cannot flood the host while its dial is in flight", async () => { + // 192.0.2.1 is TEST-NET-1 (RFC 5737): reserved for documentation, routed + // nowhere, so a SYN to it is dropped rather than refused and the dial stays + // in flight for the kernel's whole retry budget — ~130 s on Linux. That is + // the window under test, and allowlisting it is what gets the proxy to + // commit to dialling. Confirmed black-holed here by a bare connect that hung + // past 20 s with no RST and no ICMP. + const socket = proxy(["192.0.2.1"]) + const head = "CONNECT 192.0.2.1:443 HTTP/1.1\r\nHost: 192.0.2.1:443\r\n\r\n" + + const flooded = await flood({ unix: socket }, head, 4_000) + + // Precondition, asserted rather than assumed: the dial has to still be + // outstanding for this to be measuring anything. Any response at all — a 403 + // "Cannot reach", a 504 — means the environment answered TEST-NET-1 quickly + // and the window never opened, so the test would otherwise pass vacuously. + expect(flooded.response).toBe("") + + // The invariant: the proxy stopped reading, so the client cannot even + // generate the bytes — they stay in its socket buffer and then in it. This + // is backpressure rather than a cap, so there is no limit to tune; the bound + // asserted here is just the socket buffers on either side of the pause. + // Against the unbounded version this reached ~1 GiB inside 4 s. + expect(flooded.sent).toBeLessThan(16 * 1024 * 1024) + expect(flooded.stalled).toBe(true) +}, 60_000) + +test("a dial that never completes gives up and says so", async () => { + // Same black hole, but now waiting for the timeout rather than racing it. + // 150 ms stands in for the shipped 30 s so this costs the suite no real + // time; what it exercises is that the timer fires, answers, and closes, + // instead of the connection hanging for the kernel's ~130 s SYN budget. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "egress-timeout-")) + const socket = path.join(dir, "e.sock") + const server = Egress.serveProxy({ socket, rules: ["192.0.2.1"], dialTimeout: 150 }) + opened.push({ + stop: () => { + server.stop(true) + fs.rmSync(dir, { recursive: true, force: true }) + }, + }) + + const started = Date.now() + const answer = await new Promise((resolve, reject) => { + const fail = setTimeout(() => reject(new Error("the proxy never gave up on the dial")), 20_000) + const body = { text: "" } + Bun.connect({ + unix: socket, + socket: { + open(sock) { + sock.write("CONNECT 192.0.2.1:443 HTTP/1.1\r\nHost: 192.0.2.1:443\r\n\r\n") + }, + data(_sock, chunk) { + body.text += chunk.toString("latin1") + }, + close() { + clearTimeout(fail) + resolve(body.text) + }, + error() { + clearTimeout(fail) + resolve(body.text) + }, + }, + }).catch(reject) + }) + + expect(answer).toContain("504 Gateway Timeout") + expect(answer).toContain("192.0.2.1:443") + // Bounded by the budget, not by the kernel. Generous upper bound so a loaded + // machine cannot flake it, but far below the ~130 s this used to take. + expect(Date.now() - started).toBeLessThan(10_000) +}, 60_000) + +// ── seatbelt: TCP loopback + Proxy-Authorization ──────────────────────────── +// +// Everything above dials a unix socket. macOS has no network namespace to +// bind one into, so serveProxy listens directly on a loopback TCP port +// instead (Task 7 brief, decision 1 — no host-side bridge between the two; +// see the module doc comment and sandbox.ts's seatbeltProfile). A loopback +// TCP port, unlike a unix socket, carries no filesystem permissions of its +// own — every process on the machine can dial it — so that listener +// additionally requires a `Proxy-Authorization` secret (decision 2). These +// test the listener and that requirement directly, with no EgressRuntime or +// sandbox in between; `test/sandbox/egress-runtime.test.ts` covers the same +// property one layer up, through the lifecycle that actually generates and +// threads the secret in production. + +function tcpProxy(rules: string[], secret: string) { + const server = Egress.serveProxy({ hostname: "127.0.0.1", port: 0, secret, rules }) + opened.push({ stop: () => server.stop(true) }) + return server +} + +/** Speaks CONNECT directly over TCP loopback, optionally with a + * Proxy-Authorization header — the wire shape pip/curl/requests produce + * from a `http://os:@host:port` proxy URL. */ +function tcpRequest(port: number, authority: string, auth?: string): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(`no response from the proxy for ${authority}`)), 5_000) + let body = "" + const header = + auth !== undefined ? `\r\nProxy-Authorization: Basic ${Buffer.from(`os:${auth}`).toString("base64")}` : "" + Bun.connect({ + hostname: "127.0.0.1", + port, + socket: { + open(sock) { + sock.write(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}${header}\r\n\r\n`) + }, + data(_sock, chunk) { + body += chunk.toString() + }, + close() { + clearTimeout(timeout) + resolve(body) + }, + error(_sock, error) { + clearTimeout(timeout) + reject(error) + }, + }, + }).catch(reject) + }) +} + +test("serveProxy on TCP binds 127.0.0.1, never 0.0.0.0", () => { + const server = tcpProxy([], "s") + expect(server.hostname).toBe("127.0.0.1") +}) + +test("a correctly-authenticated TCP request passes auth and reaches the dial", async () => { + const port = tcpProxy(["127.0.0.1"], "right-secret").port + // 127.0.0.1:1 is allowlisted but nothing listens there (a privileged, + // essentially never-bound port) — "Cannot reach" (not 407, not "not on + // the sandbox allowlist") is what proves the secret was accepted and the + // request reached the dial, the same distinction `proxyRequest`-based + // tests elsewhere in this suite use for the unix-socket listener. + const body = await tcpRequest(port, "127.0.0.1:1", "right-secret") + expect(body).toContain("Cannot reach") +}) + +test("a TCP request with no Proxy-Authorization is refused with 407 and never reaches the dial or the allowlist check", async () => { + const port = tcpProxy(["127.0.0.1"], "right-secret").port + const body = await tcpRequest(port, "127.0.0.1:1") + expect(body).toContain("407 Proxy Authentication Required") + expect(body).not.toContain("sandbox allowlist") + expect(body).not.toContain("Cannot reach") +}) + +test("a TCP request with the wrong secret is refused with 407", async () => { + const port = tcpProxy(["127.0.0.1"], "right-secret").port + const body = await tcpRequest(port, "127.0.0.1:1", "wrong-secret") + expect(body).toContain("407 Proxy Authentication Required") +}) + +test("the unix-socket listener requires no Proxy-Authorization — auth is TCP-only", async () => { + // Regression guard for the other direction: adding auth to the TCP branch + // must not leak onto bubblewrap's unix socket, which has no `secret` to + // check in the first place — filesystem permissions on the path are its + // access control (unchanged Linux behaviour this task must not regress). + const socket = proxy(["127.0.0.1"]) + const body = await proxyRequestNoAuth(socket, "127.0.0.1:1") + expect(body).not.toContain("407") + expect(body).toContain("Cannot reach") +}) + +function proxyRequestNoAuth(socket: string, authority: string): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(`no response for ${authority}`)), 5_000) + let body = "" + Bun.connect({ + unix: socket, + socket: { + open(sock) { + sock.write(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n\r\n`) + }, + data(_sock, chunk) { + body += chunk.toString() + }, + close() { + clearTimeout(timeout) + resolve(body) + }, + error(_sock, error) { + clearTimeout(timeout) + reject(error) + }, + }, + }).catch(reject) + }) +} diff --git a/backend/cli/test/sandbox/sandbox.test.ts b/backend/cli/test/sandbox/sandbox.test.ts index f1f3d40e..f4d24a0f 100644 --- a/backend/cli/test/sandbox/sandbox.test.ts +++ b/backend/cli/test/sandbox/sandbox.test.ts @@ -1,40 +1,42 @@ import { describe, expect, test } from "bun:test" import fs from "fs" +import { builtinModules } from "module" import os from "os" import path from "path" import { Sandbox } from "../../src/sandbox/sandbox" +import { SHIM_READY_MARKER } from "../../src/sandbox/egress-shim-marker" import { tmpdir } from "../fixture/fixture" const shell = "/bin/sh" describe("Sandbox.seatbeltProfile", () => { test("denies writes by default and re-allows the workspace", () => { - const profile = Sandbox.seatbeltProfile({ writable: ["/work/project"], network: true }) + const profile = Sandbox.seatbeltProfile({ writable: ["/work/project"], network: "allow" }) expect(profile).toContain("(version 1)") expect(profile).toContain("(allow default)") expect(profile).toContain("(deny file-write*)") expect(profile).toContain('(subpath "/work/project")') }) - test("network:false adds a network deny; network:true does not", () => { - expect(Sandbox.seatbeltProfile({ writable: ["/w"], network: false })).toContain("(deny network*)") - expect(Sandbox.seatbeltProfile({ writable: ["/w"], network: true })).not.toContain("(deny network*)") + test('network:"deny" adds a network deny; network:"allow" does not', () => { + expect(Sandbox.seatbeltProfile({ writable: ["/w"], network: "deny" })).toContain("(deny network*)") + expect(Sandbox.seatbeltProfile({ writable: ["/w"], network: "allow" })).not.toContain("(deny network*)") }) test("a path outside the allowlist is not granted write access", () => { - const profile = Sandbox.seatbeltProfile({ writable: ["/work/project"], network: true }) + const profile = Sandbox.seatbeltProfile({ writable: ["/work/project"], network: "allow" }) expect(profile).not.toContain('(subpath "/etc/passwd")') expect(profile).not.toContain(process.env.HOME + "/.ssh") }) test("adds the macOS /private firmlink alias for /tmp", () => { - const profile = Sandbox.seatbeltProfile({ writable: ["/tmp"], network: true }) + const profile = Sandbox.seatbeltProfile({ writable: ["/tmp"], network: "allow" }) expect(profile).toContain('(subpath "/tmp")') expect(profile).toContain('(subpath "/private/tmp")') }) test("escapes quotes in paths so the profile cannot be broken out of", () => { - const profile = Sandbox.seatbeltProfile({ writable: ['/weird/pa"th'], network: true }) + const profile = Sandbox.seatbeltProfile({ writable: ['/weird/pa"th'], network: "allow" }) expect(profile).toContain('/weird/pa\\"th') }) @@ -42,7 +44,7 @@ describe("Sandbox.seatbeltProfile", () => { const profile = Sandbox.seatbeltProfile({ writable: ["/work/project"], unreadable: ["/home/user/.config/atlas-cli/config.json"], - network: true, + network: "allow", }) expect(profile).toContain('(deny file-read* (literal "/home/user/.config/atlas-cli/config.json"))') }) @@ -50,7 +52,7 @@ describe("Sandbox.seatbeltProfile", () => { describe("Sandbox.bubblewrapArgs", () => { test("mounts the fs read-only then re-binds the workspace writable", () => { - const args = Sandbox.bubblewrapArgs({ writable: ["/work/project"], network: true }) + const args = Sandbox.bubblewrapArgs({ writable: ["/work/project"], network: "allow" }) expect(args.slice(0, 3)).toEqual(["--ro-bind", "/", "/"]) // whole fs read-only first expect(args).toContain("--die-with-parent") const i = args.indexOf("--bind-try") @@ -59,13 +61,13 @@ describe("Sandbox.bubblewrapArgs", () => { expect(args[i + 2]).toBe("/work/project") }) - test("network:false unshares the network namespace", () => { - expect(Sandbox.bubblewrapArgs({ writable: ["/w"], network: false })).toContain("--unshare-net") - expect(Sandbox.bubblewrapArgs({ writable: ["/w"], network: true })).not.toContain("--unshare-net") + test('network:"deny" unshares the network namespace', () => { + expect(Sandbox.bubblewrapArgs({ writable: ["/w"], network: "deny" })).toContain("--unshare-net") + expect(Sandbox.bubblewrapArgs({ writable: ["/w"], network: "allow" })).not.toContain("--unshare-net") }) test("skips the /tmp tmpfs root but binds workspace paths under it", () => { - const args = Sandbox.bubblewrapArgs({ writable: ["/tmp", "/tmp/sub"], network: true }) + const args = Sandbox.bubblewrapArgs({ writable: ["/tmp", "/tmp/sub"], network: "allow" }) expect(args).toContain("--tmpfs") const binds = args.flatMap((a, n) => (a === "--bind-try" ? [args[n + 1]!] : [])) // the /tmp mount root itself is never bound from the host (the tmpfs provides it) @@ -76,7 +78,7 @@ describe("Sandbox.bubblewrapArgs", () => { }) test("unshares the PID namespace so /proc escape vectors are closed", () => { - expect(Sandbox.bubblewrapArgs({ writable: ["/w"], network: true })).toContain("--unshare-pid") + expect(Sandbox.bubblewrapArgs({ writable: ["/w"], network: "allow" })).toContain("--unshare-pid") }) test("masks host credential files with an empty device", () => { @@ -86,7 +88,7 @@ describe("Sandbox.bubblewrapArgs", () => { const args = Sandbox.bubblewrapArgs({ writable: ["/work/project"], unreadable: [file], - network: true, + network: "allow", }) const mask = args.findIndex((value, index) => value === "--ro-bind-try" && args[index + 1] === "/dev/null") expect(args.slice(mask, mask + 3)).toEqual(["--ro-bind-try", "/dev/null", file]) @@ -101,7 +103,7 @@ describe("Sandbox.bubblewrapArgs", () => { const args = Sandbox.bubblewrapArgs({ writable: ["/work/project"], unreadable: [file], - network: true, + network: "allow", }) expect(args).not.toContain(file) }) @@ -118,7 +120,7 @@ describe("Sandbox.bubblewrapArgs", () => { const args = Sandbox.bubblewrapArgs({ writable: [tmp.path], unreadable: [present, missing], - network: false, + network: "deny", }) const proc = Bun.spawn(["bwrap", ...args, "--", "/bin/echo", "ok"], { stdout: "pipe", stderr: "pipe" }) const [out, error, exit] = await Promise.all([ @@ -160,7 +162,9 @@ describe("Sandbox.plan", () => { }) test("enabled → sandboxed when a backend exists, else degrades", () => { - const p = Sandbox.plan({ ...base, options: { enabled: true } }) + // network is orthogonal to what this test checks; pin it to "allow" so the + // assertions below aren't coupled to the "allowlist" default's egress requirement + const p = Sandbox.plan({ ...base, options: { enabled: true, network: "allow" } }) if (Sandbox.available()) { expect(p.sandboxed).toBe(true) expect(["sandbox-exec", "bwrap"]).toContain(p.file) @@ -185,7 +189,7 @@ describe("Sandbox.plan", () => { shell, cwd: "/work/elsewhere", workspace: ["/work/project"], - options: { enabled: true }, + options: { enabled: true, network: "allow" }, }) const argv = (p.args ?? []).join(" ") expect(argv).toContain("/work/project") @@ -201,7 +205,7 @@ describe("Sandbox.plan", () => { shell, cwd: "/work/project", workspace: ["/work/project", "/"], - options: { enabled: true, allowWrite: [os.homedir()] }, + options: { enabled: true, network: "allow", allowWrite: [os.homedir()] }, }) const argv = (p.args ?? []).join(" ") expect(argv).toContain("/work/project") @@ -212,3 +216,978 @@ describe("Sandbox.plan", () => { expect(argv).not.toContain(`(subpath "${os.homedir()}")`) }) }) + +describe("Sandbox network policy", () => { + test("deny unshares the network and binds no socket", () => { + const args = Sandbox.bubblewrapArgs({ writable: ["/w"], network: "deny" }) + expect(args).toContain("--unshare-net") + expect(args.join(" ")).not.toContain(".sock") + }) + + test("allow neither unshares nor binds", () => { + const args = Sandbox.bubblewrapArgs({ writable: ["/w"], network: "allow" }) + expect(args).not.toContain("--unshare-net") + }) + + // The namespace must stay severed — the socket is the ONLY route out. If + // --unshare-net were dropped here the proxy would become advisory. + test("allowlist unshares the network AND binds the socket read-only", () => { + const args = Sandbox.bubblewrapArgs({ writable: ["/w"], network: "allowlist", egress: "/run/os/e.sock" }) + expect(args).toContain("--unshare-net") + const at = args.indexOf("/run/os/e.sock") + expect(at).toBeGreaterThan(0) + // --ro-bind, not --bind: the bind shares the host inode, so a read-write + // bind would let a sandboxed process `chmod 000` the socket and disable + // egress host-wide (persists past this process, shared by every + // kernel/terminal/job). Read-only blocks chmod while still permitting + // connect() — verified live in the fix-round report. + expect(args[at - 1]).toBe("--ro-bind") + }) + + test("allowlist without a socket path is refused rather than silently opened", () => { + expect(() => Sandbox.bubblewrapArgs({ writable: ["/w"], network: "allowlist" })).toThrow() + }) + + // buildPolicy filters `egress` through the same tooBroadToConfine gate as + // `writable`/`unreadable`, normalized the same way (dedupe()'s path.resolve()) + // before the gate sees it — so a lexical variant of an over-broad path (a + // trailing slash, a double slash, an unresolved "..") can't slip past the + // gate's string checks the way the raw string comparison once did. An + // over-broad egress must never reach argv as a --bind at all — even + // read-only, that would expose the whole subtree's contents, not just + // widen network access. Proven previously by calling bubblewrapArgs + // directly with an unfiltered + // `egress: $HOME`, which emitted "--bind $HOME $HOME" and let a sandboxed + // write escape to the real $HOME — and, before normalization was added, the + // exact same escape via `egress: $HOME + "/"` (a trailing slash was enough + // to dodge the raw string check). Going through the public plan() (which + // runs buildPolicy) instead: the over-broad path is dropped, so "allowlist" + // is left without an egress socket and refuses to run — it fails closed + // rather than silently binding it, for the whole class of lexical variants, + // not just the one literal string. + test.each([ + ["exact", os.homedir()], + ["trailing slash", os.homedir() + "/"], + ["double slash", os.homedir() + "//"], + ["unresolved ..", os.homedir() + "/foo/.."], + ["root", "/"], + ])("an over-broad egress path (%s) is dropped, not bound as a read-write escape hatch", (_label, egress) => { + // platform "linux", not the ambient one: this asserts bubblewrap argv, + // and on a darwin runner the same call reaches `seatbeltProfile` and + // throws for an entirely different reason ("requires an egress port"), + // which a bare .toThrow() would have accepted as a pass. Asserting the + // message closes that hole for good. + expect(() => + Sandbox.plan({ + command: "true", + shell, + cwd: "/work/project", + workspace: ["/work/project"], + options: { enabled: true, network: "allowlist", egress }, + platform: "linux", + }), + ).toThrow("requires an egress socket path") + }) + + test("a legitimate, non-broad egress socket is still bound, read-only", () => { + const p = Sandbox.plan({ + command: "true", + shell, + cwd: "/work/project", + workspace: ["/work/project"], + options: { enabled: true, network: "allowlist", egress: "/run/os/e.sock" }, + platform: "linux", + }) + expect(p.args).toContain("/run/os/e.sock") + const at = (p.args ?? []).indexOf("/run/os/e.sock") + expect((p.args ?? [])[at - 1]).toBe("--ro-bind") + }) + + // Live regression for the read-only bind's actual purpose: the egress + // socket is one-per-CLI-process, shared by every kernel/terminal/job, so a + // sandboxed process that could `chmod 000` it would disable egress + // host-wide until restart (the bind shares the host inode, so the mode + // change persists on the host — even the host proxy can no longer + // connect()). Runs a real bwrap with the exact args bubblewrapArgs() + // produces (not the full shim/proxy plan() composes, which is exercised + // elsewhere) to prove both properties of --ro-bind at once: chmod fails + // closed inside the sandbox, and a plain client can still connect() + // through the same bind. + const python = Bun.which("python3") ?? Bun.which("python") + test.skipIf(Sandbox.backend() !== "bubblewrap" || !python)( + "the sandboxed process can connect through the egress bind but cannot chmod it", + async () => { + await using tmp = await tmpdir() + const sockPath = path.join(tmp.path, "e.sock") + const received: string[] = [] + const server = Bun.listen({ + unix: sockPath, + socket: { + data(sock, chunk) { + received.push(chunk.toString()) + sock.write(`ACK:${chunk.toString()}`) + }, + }, + }) + try { + const args = Sandbox.bubblewrapArgs({ writable: [tmp.path], network: "allowlist", egress: sockPath }) + + const chmod = Bun.spawnSync({ + cmd: ["bwrap", ...args, "--", "chmod", "000", sockPath], + stdout: "pipe", + stderr: "pipe", + }) + expect(chmod.exitCode).not.toBe(0) + expect(chmod.stderr.toString()).toContain("Read-only file system") + // Mode must be unchanged on the host — the whole point of --ro-bind. + expect(fs.statSync(sockPath).mode & 0o777).toBeGreaterThan(0) + + // Bun.spawn, not spawnSync: the server above replies from a Bun.listen + // "data" callback on this same event loop, so a *synchronous* spawn + // would block that loop for as long as the child runs — the child + // blocks on recv() waiting for a reply the loop can't yet deliver, + // deadlocking both sides until the test times out (reproduced while + // writing this test). + const clientSource = [ + "import socket", + "s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)", + `s.connect(${JSON.stringify(sockPath)})`, + "s.send(b'hi')", + "print(s.recv(1024).decode(), end='')", + ].join("\n") + const connectProc = Bun.spawn({ + cmd: ["bwrap", ...args, "--", python!, "-c", clientSource], + stdout: "pipe", + stderr: "pipe", + }) + const [connectOut, connectErr] = await Promise.all([ + new Response(connectProc.stdout).text(), + new Response(connectProc.stderr).text(), + ]) + await connectProc.exited + expect(connectOut, connectErr).toBe("ACK:hi") + expect(received).toContain("hi") + } finally { + server.stop(true) + } + }, + ) + + // Seatbelt has no namespace, so bwrap's --unshare-net has no equivalent + // here: the profile text itself is the only boundary. "allowlist" is + // therefore carried by Policy.port, not Policy.egress (that field stays + // bubblewrap's unix socket path — see Policy's doc comment). + // + // Task 7 fix round 1, I3: this used to emit only network-outbound, spelled + // (remote ip ...). docs/adr/0002-sandbox-network-policy.md:56-59 records + // the reference implementation as permitting network-bind/network-inbound/ + // network-outbound, all narrowed to the proxy's loopback port, spelled tcp + // — a narrower, unmeasured guess on the one platform this project cannot + // execute against is exactly the failure mode that makes "allowlist" + // silently unreachable on every real Mac. See seatbeltProfile's own doc + // comment for why network-bind/network-inbound are included even though + // this sandboxed process is only ever a TCP client, never a listener. + test("allowlist with a port emits deny before all three narrow allows, spelled tcp", () => { + const profile = Sandbox.seatbeltProfile({ writable: ["/w"], network: "allowlist", port: 54321 }) + const deny = profile.indexOf("(deny network*)") + expect(deny).toBeGreaterThan(-1) + for (const line of [ + '(allow network-bind (local tcp "localhost:54321"))', + '(allow network-inbound (local tcp "localhost:54321"))', + '(allow network-outbound (remote tcp "localhost:54321"))', + ]) { + const at = profile.indexOf(line) + expect(at).toBeGreaterThan(deny) + } + }) + + // The safety rule from the task brief: a missing/invalid port must never + // silently downgrade to a plain deny (which would look identical to a user + // asking for network:"deny", not what "allowlist" means) or, worse, to an + // unfiltered allow. Same fail-closed contract bubblewrapArgs already + // applies to a missing egress socket. + test("allowlist with no port throws rather than silently degrading to deny", () => { + expect(() => Sandbox.seatbeltProfile({ writable: ["/w"], network: "allowlist" })).toThrow( + "sandbox network 'allowlist' requires an egress port", + ) + }) + + // Task 7 fix round 1, M5: 65536 and above are not valid TCP ports at all; + // an unbounded check let them through and would have composed a profile + // narrowing egress to a port number that can never exist. + test.each([ + ["zero", 0], + ["negative", -1], + ["fractional", 3.5], + ["one past the max valid port", 65536], + ["absurdly large", 1e21], + ])("allowlist with an invalid port (%s) throws", (_label, port) => { + expect(() => Sandbox.seatbeltProfile({ writable: ["/w"], network: "allowlist", port })).toThrow( + "sandbox network 'allowlist' requires an egress port", + ) + }) + + test("allowlist accepts the maximum valid port, 65535", () => { + expect(() => Sandbox.seatbeltProfile({ writable: ["/w"], network: "allowlist", port: 65535 })).not.toThrow() + }) + + // The dangerous direction named in the brief: a malformed or over-broad + // allow is the only failure mode that makes a macOS user worse off than + // today's plain deny. Pin the exact shapes seatbeltProfile can produce — + // never a bare, unfiltered allow of any of the three network operations. + test("never emits an unfiltered network-bind/network-inbound/network-outbound allow in any mode", () => { + for (const network of ["deny", "allow"] as const) { + const profile = Sandbox.seatbeltProfile({ writable: ["/w"], network }) + expect(profile).not.toContain("(allow network-bind") + expect(profile).not.toContain("(allow network-inbound") + expect(profile).not.toContain("(allow network-outbound") + } + const profile = Sandbox.seatbeltProfile({ writable: ["/w"], network: "allowlist", port: 4000 }) + const lines = profile.split("\n").filter((line) => /network-(bind|inbound|outbound)/.test(line)) + expect(lines).toEqual([ + '(allow network-bind (local tcp "localhost:4000"))', + '(allow network-inbound (local tcp "localhost:4000"))', + '(allow network-outbound (remote tcp "localhost:4000"))', + ]) + }) + + // Pins the deny/allow branches byte-for-byte: Policy.port only ever + // affects the "allowlist" branch, so these two must come out exactly as + // they did before this field existed. + test("deny and allow profiles are unaffected by the allowlist port machinery", () => { + const deny = Sandbox.seatbeltProfile({ writable: ["/w"], network: "deny" }) + expect(deny.split("\n")).toEqual([ + "(version 1)", + "(allow default)", + "(deny network*)", + "(deny file-write*)", + '(allow file-write* (subpath "/w"))', + '(allow file-write* (subpath "/dev"))', + ]) + const allow = Sandbox.seatbeltProfile({ writable: ["/w"], network: "allow" }) + expect(allow.split("\n")).toEqual([ + "(version 1)", + "(allow default)", + "(deny file-write*)", + '(allow file-write* (subpath "/w"))', + '(allow file-write* (subpath "/dev"))', + ]) + }) +}) + +describe("Sandbox.backend(platform)", () => { + // The injectable seam every darwin-only assertion in this branch depends + // on: nobody on this project can install sandbox-exec, so exercising the + // seatbelt code paths in plan()/wrapArgv()/EgressRuntime from Linux is + // only possible if `platform` overrides the real, probed detection below. + test("darwin resolves to seatbelt regardless of the machine actually running the test", () => { + expect(Sandbox.backend("darwin")).toBe("seatbelt") + }) + + test("linux resolves to bubblewrap regardless of the machine actually running the test", () => { + expect(Sandbox.backend("linux")).toBe("bubblewrap") + }) + + test("an unsupported platform resolves to none", () => { + // win32 is no longer one — it maps to appcontainer when injected. freebsd + // has no backend and is not planned to get one, so it still exercises the + // fallthrough this test exists for. + expect(Sandbox.backend("freebsd")).toBe("none") + }) + + test("an injected win32 resolves to appcontainer, so the Windows paths are reachable", () => { + // The same seam that let the seatbelt paths be built from Linux. It is + // deliberately NOT the live probe: `detected()` still answers "none" on a + // real Windows machine until a launcher exists, because claiming a sandbox + // the product cannot apply is worse than refusing to run kernels there. + expect(Sandbox.backend("win32")).toBe("appcontainer") + }) + + // The doc comment's exact claim: an explicit platform that matches the + // real one is the same code path as the zero-arg call, not a parallel + // implementation that could drift from the probed one. + test("an explicitly-matching platform is identical to the zero-arg call", () => { + expect(Sandbox.backend(process.platform)).toBe(Sandbox.backend()) + }) +}) + +describe("Sandbox.plan/wrapArgv on darwin (seatbelt)", () => { + // ":" — the shape EgressRuntime.egressFor() produces for + // seatbelt (see egress-runtime.ts); buildPolicy splits it back into + // Policy.port/Policy.secret. + const port = "54321" + const secret = "topsecret123" + const egress = `${port}:${secret}` + + test("plan composes no shim and dials the proxy port directly, with the secret in the proxy URL", () => { + const p = Sandbox.plan({ + command: "echo hi", + shell, + cwd: "/work/project", + workspace: ["/work/project"], + options: { enabled: true, network: "allowlist", egress }, + platform: "darwin", + }) + expect(p.sandboxed).toBe(true) + expect(p.backend).toBe("seatbelt") + expect(p.file).toBe("sandbox-exec") + const argv = (p.args ?? []).join(" ") + // No unix-socket shim exists on darwin: no launcher, no bundle, no + // __egress-shim marker — the real command runs directly under sandbox-exec. + expect(argv).not.toContain("__egress-shim") + expect(argv).toContain("echo hi") + // A loopback TCP port carries no filesystem permissions of its own (a + // unix socket does), so the URL embeds the per-start secret as userinfo + // — pip/curl/requests all parse this into Proxy-Authorization. + expect(p.env?.HTTP_PROXY).toBe(`http://os:${secret}@127.0.0.1:${port}`) + expect(p.env?.http_proxy).toBe(p.env?.HTTP_PROXY) + }) + + test("wrapArgv composes no shim and dials the proxy port directly, with the secret in the proxy URL", () => { + const w = Sandbox.wrapArgv({ + file: "python3", + args: ["-u", "/tmp/k.py"], + workspace: ["/work/project"], + options: { enabled: true, network: "allowlist", egress }, + platform: "darwin", + }) + expect(w.sandboxed).toBe(true) + expect(w.backend).toBe("seatbelt") + const argv = w.args.join(" ") + expect(argv).not.toContain("__egress-shim") + expect(argv).toContain("python3") + expect(w.env?.HTTP_PROXY).toBe(`http://os:${secret}@127.0.0.1:${port}`) + }) + + test("allowlist with no egress port throws rather than silently degrading to deny", () => { + expect(() => + Sandbox.wrapArgv({ + file: "python3", + args: ["-u", "/tmp/k.py"], + workspace: ["/work/project"], + options: { enabled: true, network: "allowlist" }, + platform: "darwin", + }), + ).toThrow() + }) + + // A port with no secret is exactly as fail-closed as no port at all — see + // buildPolicy's doc comment: the two are validated and dropped together, + // so a malformed "port with no secret" pairing can never compose a proxy + // URL missing the credential the darwin listener requires. + test("allowlist with a port but no secret (malformed egress) throws, not an unauthenticated proxy URL", () => { + expect(() => + Sandbox.wrapArgv({ + file: "python3", + args: ["-u", "/tmp/k.py"], + workspace: ["/work/project"], + options: { enabled: true, network: "allowlist", egress: `${port}:` }, + platform: "darwin", + }), + ).toThrow() + }) + + test("deny and allow never compose a shim or set a proxy env on darwin", () => { + const deny = Sandbox.wrapArgv({ + file: "python3", + args: ["-u", "/tmp/k.py"], + workspace: ["/work/project"], + options: { enabled: true, network: "deny" }, + platform: "darwin", + }) + expect(deny.args.join(" ")).not.toContain("__egress-shim") + expect(deny.env).toBeUndefined() + + const allow = Sandbox.wrapArgv({ + file: "python3", + args: ["-u", "/tmp/k.py"], + workspace: ["/work/project"], + options: { enabled: true, network: "allow" }, + platform: "darwin", + }) + expect(allow.args.join(" ")).not.toContain("__egress-shim") + expect(allow.env).toBeUndefined() + }) + + // Regression guard for the real-platform paths: an explicit platform that + // matches this machine's own must not diverge from the zero-arg call — + // "allow" keeps this cheap (no shim/proxy machinery) while still routing + // through decide()/buildPolicy() with a platform argument threaded in. + test("an explicitly-matching platform reproduces the zero-arg plan on this machine", () => { + const base = { + command: "echo hi", + shell, + cwd: "/work/project", + workspace: ["/work/project"], + options: { enabled: true, network: "allow" as const }, + } + expect(Sandbox.plan({ ...base, platform: process.platform })).toEqual(Sandbox.plan(base)) + }) +}) + +describe("Sandbox.shimScript", () => { + test("backgrounds the shim and execs the real command", () => { + const script = Sandbox.shimScript({ + binary: "/usr/bin/openscience", + port: 3128, + socket: "/run/os/e.sock", + file: "python3", + args: ["-u", "/tmp/k.py"], + }) + expect(script).toContain("__egress-shim") + expect(script).toContain("&") + expect(script).toContain("exec ") + }) + + test("quotes every interpolated value so a path with a space cannot split", () => { + const script = Sandbox.shimScript({ + binary: "/opt/my apps/openscience", + port: 3128, + socket: "/run/my dir/e.sock", + file: "python3", + args: ["-c", "print('hi there')"], + }) + expect(script).toContain("'/opt/my apps/openscience'") + expect(script).toContain("'/run/my dir/e.sock'") + expect(script).not.toMatch(/[^']\/opt\/my apps/) + }) + + test("a single quote in an argument cannot break out of the quoting", () => { + const script = Sandbox.shimScript({ + binary: "/usr/bin/openscience", + port: 3128, + socket: "/run/os/e.sock", + file: "python3", + args: ["-c", "x = 'a'; print(x)"], + }) + expect(script).toContain(`'"'"'`) + }) +}) + +// The readiness wait is the whole per-spawn cost of network "allowlist", and +// it is paid by every sandboxed command whether or not it touches the network. +// These run the composed script through a real /bin/sh — the only place its +// behaviour actually lives — with a stand-in for the shim binary, so they need +// no bubblewrap and no proxy. +describe("Sandbox.shimScript readiness wait", () => { + const posix = process.platform !== "win32" + + /** Runs the composed script and reports how long it took, plus whatever the + * wait leaked to the real command's stderr (it runs in the foreground, so + * anything it prints lands in the command's own output). */ + async function run(script: string, prefixPath?: string) { + const started = Date.now() + const proc = Bun.spawn(["/bin/sh", "-c", script], { + stdout: "pipe", + stderr: "pipe", + env: prefixPath ? { ...process.env, PATH: `${prefixPath}:${process.env["PATH"]}` } : process.env, + }) + const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]) + await proc.exited + return { ms: Date.now() - started, stdout, stderr } + } + + /** A `sleep` that rejects fractional intervals the way some busybox builds + * do, so the fallback branch is exercised on a host whose real coreutils + * accepts them. */ + function busyboxishSleep(dir: string) { + const real = Bun.which("sleep") ?? "/bin/sleep" + const file = path.join(dir, "sleep") + fs.writeFileSync( + file, + `#!/bin/sh\ncase "$1" in\n *.*) echo "sleep: invalid number '$1'" >&2; exit 1 ;;\nesac\nexec ${real} "$@"\n`, + { mode: 0o755 }, + ) + return dir + } + + /** A `sleep` whose cost is dominated by process creation rather than by the + * interval asked for. A macOS CI runner measured ~114ms per iteration of + * the 0.02s poll — ~94ms of fork/exec — which stretched the nominal 3s cap + * to 17.1s and is what put the wall-clock deadline in `shimScript`. This + * reproduces that condition on any host. */ + function expensiveSleep(dir: string) { + const real = Bun.which("sleep") ?? "/bin/sleep" + const file = path.join(dir, "sleep") + fs.writeFileSync(file, `#!/bin/sh\nexec ${real} 0.12\n`, { mode: 0o755 }) + return dir + } + + test.skipIf(!posix)( + "waits for the shim, and only for as long as the shim takes", + async () => { + await using dir = await tmpdir() + // Stands in for the shim: ignores its arguments, becomes ready quickly. + const fake = path.join(dir.path, "shim") + fs.writeFileSync(fake, `#!/bin/sh\nsleep 0.15\n: > ${JSON.stringify(SHIM_READY_MARKER)}\n`, { mode: 0o755 }) + fs.rmSync(SHIM_READY_MARKER, { force: true }) + + try { + const { ms, stdout } = await run( + Sandbox.shimScript({ binary: fake, port: 3128, socket: "/run/os/e.sock", file: "/bin/echo", args: ["ran"] }), + ) + expect(stdout.trim()).toBe("ran") + // It really waited: the marker only lands at ~150ms. + expect(ms).toBeGreaterThanOrEqual(140) + // And it did not round that up to a whole second. Before the poll + // interval was chosen at run time this was a flat ~1.0s for a shim that + // is ready in ~12ms — measured 1006ms against 3ms for network "deny". + expect(ms).toBeLessThan(600) + } finally { + fs.rmSync(SHIM_READY_MARKER, { force: true }) + } + }, + 30_000, + ) + + test.skipIf(!posix)( + "a sleep that rejects fractions still waits, and says nothing about it", + async () => { + await using dir = await tmpdir() + fs.rmSync(SHIM_READY_MARKER, { force: true }) + // /bin/true ignores the shim arguments and never signals readiness, so the + // wait runs to its cap — which is the point: a fractional `sleep` that + // errors out returns instantly, so a loop that ignored the failure would + // spin through all its iterations in microseconds and skip the wait + // entirely, silently, while printing one error line per iteration. + const { ms, stderr } = await run( + Sandbox.shimScript({ + binary: "/bin/true", + port: 3128, + socket: "/run/os/e.sock", + file: "/bin/echo", + args: ["ran"], + }), + busyboxishSleep(dir.path), + ) + expect(stderr).toBe("") + expect(ms).toBeGreaterThanOrEqual(2_500) + expect(ms).toBeLessThan(6_000) + }, + 30_000, + ) + + test.skipIf(!posix)( + "the cap is the same 3s whichever granularity the shell supports", + async () => { + fs.rmSync(SHIM_READY_MARKER, { force: true }) + const { ms } = await run( + Sandbox.shimScript({ + binary: "/bin/true", + port: 3128, + socket: "/run/os/e.sock", + file: "/bin/echo", + args: ["ran"], + }), + ) + expect(ms).toBeGreaterThanOrEqual(2_500) + expect(ms).toBeLessThan(6_000) + }, + 30_000, + ) + + // The regression this file's macOS run found: the cap used to be an + // iteration count, so it only equalled 3s where forking `sleep` was nearly + // free. Without the deadline, 150 iterations at 0.12s each run for 18s. + test.skipIf(!posix)( + "a `sleep` whose real cost is fork/exec cannot stretch the cap", + async () => { + await using dir = await tmpdir() + fs.rmSync(SHIM_READY_MARKER, { force: true }) + const { ms, stderr } = await run( + Sandbox.shimScript({ + binary: "/bin/true", + port: 3128, + socket: "/run/os/e.sock", + file: "/bin/echo", + args: ["ran"], + }), + expensiveSleep(dir.path), + ) + // Still silent: the deadline probe's own diagnostics are discarded the + // same way the fractional-sleep probe's are. + expect(stderr).toBe("") + expect(ms).toBeGreaterThanOrEqual(2_500) + expect(ms).toBeLessThan(6_000) + }, + 30_000, + ) +}) + +describe("Sandbox.wrapArgv egress shim", () => { + test.skipIf(Sandbox.backend() !== "bubblewrap")( + "allowlist composes the shim into the argv and returns proxy env", + () => { + const wrapped = Sandbox.wrapArgv({ + file: "python3", + args: ["-u", "/tmp/k.py"], + workspace: ["/work/project"], + options: { enabled: true, network: "allowlist", egress: "/run/os/e.sock" }, + }) + expect(wrapped.sandboxed).toBe(true) + const argv = wrapped.args.join(" ") + expect(argv).toContain("__egress-shim") + expect(argv).toContain("/run/os/e.sock") + expect(argv).toContain("exec 'python3'") + expect(wrapped.env?.HTTP_PROXY).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/) + expect(wrapped.env?.http_proxy).toBe(wrapped.env?.HTTP_PROXY) + }, + ) + + test.skipIf(Sandbox.backend() !== "bubblewrap")("deny and allow never compose the shim", () => { + const deny = Sandbox.wrapArgv({ + file: "python3", + args: ["-u", "/tmp/k.py"], + workspace: ["/work/project"], + options: { enabled: true, network: "deny" }, + }) + expect(deny.args.join(" ")).not.toContain("__egress-shim") + expect(deny.env).toBeUndefined() + + const allow = Sandbox.wrapArgv({ + file: "python3", + args: ["-u", "/tmp/k.py"], + workspace: ["/work/project"], + options: { enabled: true, network: "allow" }, + }) + expect(allow.args.join(" ")).not.toContain("__egress-shim") + expect(allow.env).toBeUndefined() + }) + + // Everything above asserts on the composed argv string without ever running + // it — which is exactly what let the shim silently die inside the real + // sandbox (EROFS from the CLI's logging middleware) go undetected. This + // spawns the actual `wrapArgv` output — real bwrap, the real composed + // `sh -c` script, and (in dev, which this test runs as) the real on-disk + // launcher `shimPlan()` writes — and proves a TCP client inside the + // namespace gets a connection accepted and bridged to the unix socket, not + // a refused one. Resolved once, from the same gate the skip condition uses + // — bash lives at /bin/bash on Alpine and non-usrmerge Debian, not + // /usr/bin/bash, and a hardcoded path there would fail instead of skip. + const bash = Bun.which("bash") + test.skipIf(Sandbox.backend() !== "bubblewrap" || !bash || !Bun.which("timeout") || !Bun.which("head"))( + "the composed script actually starts the shim inside a real sandbox and bridges a connection", + async () => { + await using tmp = await tmpdir() + const socket = path.join(tmp.path, "e.sock") + const received: string[] = [] + const server = Bun.listen({ + unix: socket, + socket: { + data(sock, chunk) { + received.push(chunk.toString()) + sock.write(`ACK:${chunk.toString()}`) + }, + }, + }) + try { + const wrapped = Sandbox.wrapArgv({ + file: bash!, + args: ["-c", "exec 3<>/dev/tcp/127.0.0.1/3128; printf hello >&3; timeout 3 head -c 9 <&3"], + workspace: [tmp.path], + options: { enabled: true, network: "allowlist", egress: socket }, + }) + const proc = Bun.spawn([wrapped.file, ...wrapped.args], { stdout: "pipe", stderr: "pipe" }) + const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]) + await proc.exited + expect(out, `stdout=${out} stderr=${err}`).toContain("ACK:hello") + expect(received).toContain("hello") + } finally { + server.stop(true) + } + }, + 15000, + ) + + // Regression guard for the bug that survived two consecutive rounds: the + // interpreter the launcher execs (`process.execPath`) can itself live + // under /tmp — a portable bun install, `$HOME` under /tmp — independent of + // where the checkout or Global.Path.bin happen to be. `shimPlan()` reads + // `process.execPath` from the *current* process, so the only way to + // actually exercise this is to run under a /tmp-staged bun. Stages a real + // copy (a symlink wouldn't reproduce — Bun resolves it) and drives a small + // standalone script through it, since `bun:test` itself isn't the thing + // under test here. + test.skipIf(Sandbox.backend() !== "bubblewrap" || !bash || !Bun.which("timeout") || !Bun.which("head"))( + "the composed script still starts the shim when the interpreter itself is staged under /tmp", + async () => { + const stage = fs.mkdtempSync(path.join(os.tmpdir(), "staged-bun-")) + const stagedBun = path.join(stage, "bun") + const driver = path.join(stage, "driver.ts") + try { + fs.copyFileSync(process.execPath, stagedBun) + fs.chmodSync(stagedBun, 0o755) + const sandboxPath = path.resolve(import.meta.dir, "..", "..", "src", "sandbox", "sandbox.ts") + fs.writeFileSync( + driver, + [ + `import path from "path"`, + `import fs from "fs"`, + `import os from "os"`, + `import { Sandbox } from ${JSON.stringify(sandboxPath)}`, + `const work = fs.mkdtempSync(path.join(os.tmpdir(), "staged-bun-driver-"))`, + `const socket = path.join(work, "e.sock")`, + `const server = Bun.listen({ unix: socket, socket: { data(sock, chunk) { sock.write("ACK:" + chunk) } } })`, + `try {`, + ` const wrapped = Sandbox.wrapArgv({`, + ` file: ${JSON.stringify(bash)},`, + ` args: ["-c", "exec 3<>/dev/tcp/127.0.0.1/3128; printf hello >&3; timeout 3 head -c 9 <&3"],`, + ` workspace: [work],`, + ` options: { enabled: true, network: "allowlist", egress: socket },`, + ` })`, + ` const proc = Bun.spawn([wrapped.file, ...wrapped.args], { stdout: "pipe", stderr: "pipe" })`, + ` const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()])`, + ` await proc.exited`, + ` console.log(out.includes("ACK:hello") ? "STAGED_BUN_PASS" : "STAGED_BUN_FAIL " + JSON.stringify({ out, err }))`, + `} finally {`, + ` server.stop(true)`, + ` fs.rmSync(work, { recursive: true, force: true })`, + `}`, + ].join("\n"), + ) + const proc = Bun.spawn([stagedBun, "run", driver], { stdout: "pipe", stderr: "pipe" }) + const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]) + await proc.exited + expect(out, `stdout=${out} stderr=${err}`).toContain("STAGED_BUN_PASS") + } finally { + fs.rmSync(stage, { recursive: true, force: true }) + } + }, + 20000, + ) + + // Regression guard for the fifth variant of "a path the shim needs is + // masked by --tmpfs /tmp": an npm import in the shim's graph resolves + // through a node_modules symlink whose target is the monorepo-root store, + // above the package root, so binding the package root left the target + // unbound. Reproducing that directly needs a /tmp-relocated checkout with a + // real hoisted store — a fixture too elaborate to keep honest here. These + // two tests assert the property that makes the whole class impossible + // instead: the shim resolves nothing from disk at run time. + // + // First, statically, on the artifact `shimPlan()` actually generated: a + // bundle with no import specifiers left in it cannot resolve anything, + // whether the import was a sibling file or a package. Builtins are allowed + // through — they come from inside bun, not the filesystem — so this keeps + // passing if the shim ever imports node:net, and fails if a future import + // is left external or the plan goes back to executing source. It does not + // see a native binding: `dlopen("…so")` in a bundled dependency is not an + // import specifier (see shimPlan's residual list). + // + // Classifying a specifier is the whole guard, so it is done against the + // real builtin list rather than by prefix. `bun build` *strips* the node: + // prefix — a bundled `import "node:net"` comes out as `from "net"` — so a + // prefix test flags a legitimate builtin, and the natural reaction to that + // false alarm is to loosen the one check standing between here and variant + // six. In the other direction a bare /^bun/ would quietly excuse any + // package named bun-something. builtinModules already carries bun's own + // entries (bun, bun:ffi, …), so stripping node: and asking it is both + // directions at once. + test.skipIf(Sandbox.backend() !== "bubblewrap")("the generated dev shim bundle resolves nothing from disk", () => { + const wrapped = Sandbox.wrapArgv({ + file: "python3", + args: ["-u", "/tmp/k.py"], + workspace: ["/work/project"], + options: { enabled: true, network: "allowlist", egress: "/run/os/e.sock" }, + }) + const bundle = wrapped.args.find((value) => value.endsWith(".mjs")) + expect(bundle, `argv=${wrapped.args.join(" ")}`).toBeDefined() + const source = fs.readFileSync(bundle!, "utf8") + // The three shapes an unbundled dependency can survive as: `from "x"` + // (covers `import x from` and `export … from`), a call — `require("x")`, + // `import("x")` — and a bare side-effect `import "x"`. + const found = [ + ...source.matchAll(/\bfrom\s*"([^"]+)"|\b(?:require|import)\(\s*"([^"]+)"\s*\)|\bimport\s*"([^"]+)"/g), + ] + const builtin = new Set(builtinModules) + const external = found + .map((match) => match[1] ?? match[2] ?? match[3]!) + .filter((spec) => !builtin.has(spec.replace(/^node:/, ""))) + expect(external).toEqual([]) + }) + + // Second, live: mask the shim's own source entry with /dev/null (the + // sandbox's existing `unreadable` mechanism) and run the real composed + // script anyway. If the shim still bridges, nothing it ran came from the + // source tree — which is what makes where that tree lives, and what it + // imports, irrelevant. Executing the source instead reads an empty file, + // starts no listener, and the connection is refused (verified: pointing the + // launcher back at the entry fails this test in 3s, the readiness cap). + // The mask only holds because readBind is files: a readBind *directory* + // over the entry would re-expose it and this test would pass regardless, + // which is why the static check above is the one that pins the bundle. + test.skipIf(Sandbox.backend() !== "bubblewrap" || !bash || !Bun.which("timeout") || !Bun.which("head"))( + "the composed script still starts the shim when its own source entry is masked", + async () => { + await using tmp = await tmpdir() + const socket = path.join(tmp.path, "e.sock") + const server = Bun.listen({ + unix: socket, + socket: { + data(sock, chunk) { + sock.write(`ACK:${chunk.toString()}`) + }, + }, + }) + try { + const wrapped = Sandbox.wrapArgv({ + file: bash!, + args: ["-c", "exec 3<>/dev/tcp/127.0.0.1/3128; printf hello >&3; timeout 3 head -c 9 <&3"], + workspace: [tmp.path], + unreadable: [path.resolve(import.meta.dir, "..", "..", "src", "sandbox", "egress-shim-entry.ts")], + options: { enabled: true, network: "allowlist", egress: socket }, + }) + const proc = Bun.spawn([wrapped.file, ...wrapped.args], { stdout: "pipe", stderr: "pipe" }) + const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]) + await proc.exited + expect(out, `stdout=${out} stderr=${err}`).toContain("ACK:hello") + } finally { + server.stop(true) + } + }, + 15000, + ) + + // End-to-end guard on the property Important B was about: self-hosting — + // opening OpenScience on its own checkout, the case Task 5 will dogfood — + // must not lose write access to part of the workspace just because the + // sandbox also needs some path bound read-only. It caught a real bug when + // shimPlan's readBind held the package root and bubblewrapArgs emitted it + // after the writable --bind-try loop. That trigger is gone (readBind is now + // two generated files plus the interpreter, none inside a checkout), so the + // test no longer has a failing negative control — it passes by the bind set + // being small rather than by the overlap exclusion doing anything. Kept as + // the assertion that the property still holds however the set changes. + test.skipIf(Sandbox.backend() !== "bubblewrap" || !bash)( + "a workspace path under the package root stays writable under allowlist", + async () => { + await using tmp = await tmpdir() + const socket = path.join(tmp.path, "e.sock") + const server = Bun.listen({ unix: socket, socket: { data() {} } }) + const packageRoot = path.resolve(import.meta.dir, "..", "..") + const probe = path.join(packageRoot, "src", "sandbox", `.regression-write-probe-${process.pid}`) + try { + const wrapped = Sandbox.wrapArgv({ + file: bash!, + args: ["-c", `echo probe > '${probe}' && cat '${probe}' && rm '${probe}' && echo WRITE_OK`], + workspace: [packageRoot], + options: { enabled: true, network: "allowlist", egress: socket }, + }) + const proc = Bun.spawn([wrapped.file, ...wrapped.args], { stdout: "pipe", stderr: "pipe" }) + const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]) + await proc.exited + expect(out, `stdout=${out} stderr=${err}`).toContain("WRITE_OK") + } finally { + server.stop(true) + fs.rmSync(probe, { force: true }) + } + }, + 15000, + ) +}) + +describe("Sandbox on win32 (AppContainer composition)", () => { + const base = { + file: "python3", + args: ["-u", "/w/k.py"], + workspace: ["/w/project"], + platform: "win32" as const, + } + const decode = (args: string[]) => + JSON.parse(Buffer.from(args[args.indexOf("__appcontainer-launch") + 1]!, "base64").toString("utf8")) + + test("the profile name is stable for a workspace and distinct between workspaces", () => { + // The package SID is derived from this name, and filesystem ACEs plus the + // broker pipe's DACL refer to that SID. A fresh name per launch would + // strand every ACE the previous one granted; a shared name across projects + // would let one read another's granted paths. + expect(Sandbox.appContainerProfile(["/w/project"])).toBe(Sandbox.appContainerProfile(["/w/project"])) + expect(Sandbox.appContainerProfile(["/w/a"])).not.toBe(Sandbox.appContainerProfile(["/w/b"])) + }) + + test("the profile name is a legal AppContainer name", () => { + // Windows limits both length and character set, and a workspace path + // carries separators, drive letters and spaces that are not valid in one. + const name = Sandbox.appContainerProfile(["C:\\Users\\me\\My Project (v2)"]) + expect(name).toMatch(/^[A-Za-z0-9.-]{1,64}$/) + }) + + test("wrapArgv launches the binary as its own container launcher", () => { + // There is no wrapper executable on Windows: confinement is applied AT + // CreateProcess through SECURITY_CAPABILITIES, which cannot be expressed as + // an argv. The binary becomes the launcher, exactly as __egress-shim does. + const w = Sandbox.wrapArgv({ ...base, options: { enabled: true, network: "allow" } }) + expect(w.sandboxed).toBe(true) + expect(w.backend).toBe("appcontainer") + expect(w.file).toBe(process.execPath) + expect(w.args[0]).toBe("__appcontainer-launch") + }) + + test("the real argv survives at the tail, after a --", () => { + const w = Sandbox.wrapArgv({ ...base, options: { enabled: true, network: "allow" } }) + expect(w.args.slice(-3)).toEqual(["python3", "-u", "/w/k.py"]) + expect(w.args[w.args.length - 4]).toBe("--") + }) + + test("the policy travels as one base64 blob, not as flags", () => { + // Windows re-parses command lines with CommandLineToArgvW rules that differ + // from every shell, and paths there routinely carry spaces, quotes and + // backslashes. A blob with no shell-significant characters cannot be mangled. + const w = Sandbox.wrapArgv({ ...base, options: { enabled: true, network: "allow" } }) + const blob = w.args[1]! + expect(blob).toMatch(/^[A-Za-z0-9+/=]+$/) + const spec = decode(w.args) + expect(spec.profile).toBe(Sandbox.appContainerProfile(["/w/project"])) + expect(spec.writable).toContain("/w/project") + expect(spec.network).toBe("allow") + }) + + test("allowlist carries the broker pipe; deny and allow carry none", () => { + const withPipe = Sandbox.wrapArgv({ + ...base, + options: { enabled: true, network: "allowlist", egress: "openscience-broker-abc" }, + }) + expect(decode(withPipe.args).pipe).toBe("openscience-broker-abc") + + for (const network of ["deny", "allow"] as const) { + const w = Sandbox.wrapArgv({ ...base, options: { enabled: true, network } }) + expect(decode(w.args).pipe).toBeUndefined() + } + }) + + test("composing without a profile throws rather than launching unconfined", () => { + // Fail closed, the same rule bubblewrapArgs applies to a missing egress + // socket: a launch with no profile has no package SID, so nothing is + // contained and every ACE and DACL downstream refers to nothing. + expect(() => Sandbox.appContainerArgs({ writable: ["/w"], network: "allow" }, ["cmd"])).toThrow("profile") + }) + + test("the real Windows backend is probed, never assumed", async () => { + // This test previously asserted the probe never said "appcontainer" at all, + // which was correct while no launcher existed. Now one does, so the + // invariant moves rather than disappears: win32 must resolve through + // AppContainer.usable(), which loads the DLLs and derives a SID, and must + // fall back to "none" when that fails. Returning "appcontainer" because the + // platform says win32 is how a product ends up claiming a sandbox it never + // applies. + const source = await Bun.file(new URL("../../src/sandbox/sandbox.ts", import.meta.url).pathname).text() + const detected = source.slice(source.indexOf("const detected = lazy"), source.indexOf("export function backend")) + expect(detected.includes("AppContainer.usable()")).toBe(true) + expect(detected.includes('AppContainer.usable() ? "appcontainer" : "none"')).toBe(true) + }) + + test("the capability probe is side-effect free and fails closed", async () => { + // It derives a SID rather than creating a profile, so a probe on a machine + // we end up not sandboxing leaves nothing behind; and every failure path + // returns false rather than throwing, because a probe that throws would + // take down callers that only wanted to know whether a backend exists. + const source = await Bun.file(new URL("../../src/sandbox/appcontainer.ts", import.meta.url).pathname).text() + const usable = source.slice( + source.indexOf("export function usable"), + source.indexOf("export function ensureProfile"), + ) + expect(usable.includes("CreateAppContainerProfile")).toBe(false) + expect(usable.includes("catch")).toBe(true) + expect(usable.includes("return false")).toBe(true) + }) +}) diff --git a/backend/cli/test/server/notebook.test.ts b/backend/cli/test/server/notebook.test.ts index ec75bc61..63395356 100644 --- a/backend/cli/test/server/notebook.test.ts +++ b/backend/cli/test/server/notebook.test.ts @@ -190,7 +190,7 @@ describe("/notebook routes", () => { requested: expect.any(Boolean), enforced: expect.any(Boolean), backend: expect.any(String), - network: expect.stringMatching(/^(allow|deny)$/), + network: expect.stringMatching(/^(allow|allowlist|deny)$/), platform: process.platform, }, }) @@ -377,7 +377,10 @@ describe("/notebook routes", () => { const waitForStarting = async (attempt = 0): Promise => { const result = (await (await status()).json()) as { state?: string } if (result.state === "starting") return - if (attempt >= 100) throw new Error("kernel did not start") + // 500 * 10ms = 5s: covers the loopback shim's own up-to-3s readiness + // wait under sandbox network "allowlist" (the default), plus normal + // interpreter startup, with margin. See sandbox.ts's shimScript. + if (attempt >= 500) throw new Error("kernel did not start") await Bun.sleep(10) return waitForStarting(attempt + 1) } @@ -445,7 +448,10 @@ describe("/notebook routes", () => { ) const status = (await response.json()) as { active?: boolean } if (status.active) return - if (attempt >= 100) throw new Error("kernel did not start") + // 500 * 10ms = 5s: covers the loopback shim's own up-to-3s readiness + // wait under sandbox network "allowlist" (the default), plus normal + // interpreter startup, with margin. See sandbox.ts's shimScript. + if (attempt >= 500) throw new Error("kernel did not start") await Bun.sleep(10) return waitForKernel(attempt + 1) } @@ -728,7 +734,10 @@ describe("/notebook routes", () => { const waitForStarting = async (attempt = 0): Promise => { const result = (await (await status()).json()) as { state?: string } if (result.state === "starting") return - if (attempt >= 100) throw new Error("kernel did not start") + // 500 * 10ms = 5s: covers the loopback shim's own up-to-3s readiness + // wait under sandbox network "allowlist" (the default), plus normal + // interpreter startup, with margin. See sandbox.ts's shimScript. + if (attempt >= 500) throw new Error("kernel did not start") await Bun.sleep(10) return waitForStarting(attempt + 1) } @@ -794,7 +803,8 @@ describe("/notebook routes", () => { const waitForRunning = async (attempt = 0): Promise => { const response = (await (await status()).json()) as { state?: string } if (response.state === "running") return - if (attempt >= 100) throw new Error("kernel did not start running") + // See the "kernel did not start" budget above for why 500 * 10ms. + if (attempt >= 500) throw new Error("kernel did not start running") await Bun.sleep(10) return waitForRunning(attempt + 1) } @@ -864,7 +874,8 @@ describe("/notebook routes", () => { await app.request(`/kernels?sessionID=${encodeURIComponent(session.id)}`) ).json()) as typeof kernels if (current.kernels.find((value) => value.id === kernel.id)?.state === "running") return - if (attempt >= 100) throw new Error("kernel did not start running") + // See the "kernel did not start" budget above for why 500 * 10ms. + if (attempt >= 500) throw new Error("kernel did not start running") await Bun.sleep(10) return waitForRunning(attempt + 1) } diff --git a/backend/cli/test/shell/shell.test.ts b/backend/cli/test/shell/shell.test.ts index 427ef8f8..ddf79de6 100644 --- a/backend/cli/test/shell/shell.test.ts +++ b/backend/cli/test/shell/shell.test.ts @@ -88,8 +88,17 @@ test("killTree SIGKILLs a detached group even after its leader exits", async () exited: () => true, }), ) - expect(groupKill).toHaveBeenNthCalledWith(1, -4321, "SIGTERM") - expect(groupKill).toHaveBeenNthCalledWith(2, -4321, "SIGKILL") + // Filter to this test's own pid rather than asserting on the mock's + // absolute call order: process.kill is one process-wide global, and under + // a full suite run a real sandboxed spawn elsewhere can land its own + // (real, unrelated) killTree cleanup on this same mock while it's active, + // interleaving with these two calls by index without changing what this + // test is actually verifying — that ITS SIGTERM precedes ITS SIGKILL. + const own = groupKill.mock.calls.filter(([pid]) => pid === -4321) + expect(own).toEqual([ + [-4321, "SIGTERM"], + [-4321, "SIGKILL"], + ]) expect(proc.kill).not.toHaveBeenCalled() } finally { groupKill.mockRestore() diff --git a/backend/cli/test/tool/bash-refusal.test.ts b/backend/cli/test/tool/bash-refusal.test.ts new file mode 100644 index 00000000..96f4eade --- /dev/null +++ b/backend/cli/test/tool/bash-refusal.test.ts @@ -0,0 +1,163 @@ +import { expect, test } from "bun:test" +import path from "path" +import { Instance } from "../../src/project/instance" +import { Refuse } from "../../src/package/refuse" +import { BashTool } from "../../src/tool/bash" +import type { PermissionNext } from "../../src/permission/next" +import { executionSession, tmpdir } from "../fixture/fixture" + +async function context() { + const session = await executionSession() + return { + sessionID: session.id, + messageID: "", + callID: "", + agent: "research", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, + } +} + +/** + * The tokenisation `bash.ts` performs, reproduced against the same parser it + * uses, so this breaks if that tokenisation changes shape. No mock: it is the + * real tree-sitter grammar, because the whole question this file answers is + * whether a real shell line reaches the refusal — a hand-built string array + * would assert nothing about that. + */ +async function commands(line: string) { + const { Parser, Language } = await import("web-tree-sitter") + const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, { + with: { type: "file" }, + }) + await Parser.init({ locateFile: () => treeWasm }) + const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, { + with: { type: "file" }, + }) + const parser = new Parser() + parser.setLanguage(await Language.load(bashWasm)) + const tree = parser.parse(line)! + const out: string[][] = [] + for (const node of tree.rootNode.descendantsOfType("command")) { + if (!node) continue + const command: string[] = [] + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i) + if (!child) continue + if (!["command_name", "word", "string", "raw_string", "concatenation"].includes(child.type)) continue + command.push(child.text) + } + out.push(command) + } + return out +} + +test("a real parse of the venv bypass reaches the refusal", async () => { + // The exact line measured to succeed on feat/sandbox-network-policy, with no + // tool and no approval card. + const line = "python3 -m venv /w/venv && /w/venv/bin/pip install tqdm" + const parsed = await commands(line) + const refusals = parsed.map((c) => Refuse.installer(c)).filter(Boolean) + expect(refusals).toHaveLength(1) + expect(refusals[0]).toContain("package_install") +}) + +test("a compound command is refused on its installer clause, not its first clause", async () => { + const parsed = await commands("cd /w && pip install numpy") + expect(parsed.some((c) => Refuse.installer(c))).toBe(true) +}) + +test("ordinary shell work parses to no refusal", async () => { + const parsed = await commands("python analysis.py && pip list") + expect(parsed.every((c) => !Refuse.installer(c))).toBe(true) +}) + +test("an installer named only inside a quoted argument is not refused", async () => { + // `echo` is the command; the rest are its operands. A regex over the raw + // line would refuse this and be wrong. + const parsed = await commands(`echo "pip install numpy"`) + expect(parsed.every((c) => !Refuse.installer(c))).toBe(true) +}) + +// The tests above prove the matcher and the tokenisation. These prove the tool +// actually calls it — without them the refusal could be dead code and every +// other test in this file would still be green. + +test("the real bash tool refuses an install and never runs it", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + const failure = await bash + .execute({ command: "pip install tqdm", description: "Install tqdm" }, await context()) + .then( + () => undefined, + (error: Error) => error, + ) + expect(failure?.message).toContain("package_install") + }, + }) +}) + +test("the refusal happens before the permission ask, not after", async () => { + // Otherwise the user is asked to approve a command that is then refused + // anyway — a prompt whose only possible outcome is an error. + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + const requests: Array> = [] + const ctx = { + ...(await context()), + ask: async (req: Omit) => { + requests.push(req) + }, + } + await bash.execute({ command: "pip install tqdm", description: "Install tqdm" }, ctx).catch(() => {}) + expect(requests).toHaveLength(0) + }, + }) +}) + +test("the real bash tool still runs an ordinary command", async () => { + // The refusal must not have become a blanket denial. + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + const result = await bash.execute({ command: "echo ok", description: "Echo" }, await context()) + expect(result.metadata.output).toContain("ok") + }, + }) +}) + +test("sandbox status never claims confinement on a machine with no backend", async () => { + // Observed on Windows: "status enabled (agent shell commands are confined to + // the workspace)" printed on a machine where Sandbox.backend() is "none" and + // nothing confines anything. A false statement about a security property is + // the worst thing this command can print, so the sentence now keys off + // whether a backend EXISTS, not merely off the config being on. + const source = await Bun.file(new URL("../../src/cli/cmd/sandbox.ts", import.meta.url).pathname).text() + expect(source.includes("are NOT confined here: no backend on this platform")).toBe(true) + // Three states, not two — the ternary must consider availability. + expect(source.includes("d.available")).toBe(true) +}) + +test("nothing printed on a backend-less machine carries non-ASCII", async () => { + // A Windows console decodes our UTF-8 as its OEM code page. An em dash in the + // "unavailable" line arrived as mojibake in a real run. Everything reachable + // WITHOUT a backend — which is exactly the Windows path — stays ASCII. + const source = await Bun.file(new URL("../../src/cli/cmd/sandbox.ts", import.meta.url).pathname).text() + const printed = source + .split("\n") + .filter((l) => l.includes("UI.println") || l.includes("TEXT_WARNING") || l.includes("TEXT_DANGER")) + .filter((l) => !l.includes("c.skipped") && !l.trimStart().startsWith("//")) + .join("\n") + // eslint-disable-next-line no-control-regex + expect(printed).not.toMatch(/[^\x00-\x7F]/) +}) diff --git a/backend/cli/test/tool/command-runtime.test.ts b/backend/cli/test/tool/command-runtime.test.ts index d04764c0..0e284f37 100644 --- a/backend/cli/test/tool/command-runtime.test.ts +++ b/backend/cli/test/tool/command-runtime.test.ts @@ -29,7 +29,10 @@ test("bash registers only its live process in the project compute ledger", async const find = async (attempt = 0): Promise[number]> => { const command = CommandRuntime.list(Instance.project.id, session.id)[0] if (command) return command - if (attempt >= 100) throw new Error("Live command did not enter the compute ledger") + // 500 * 10ms = 5s: the real command doesn't start until the loopback + // shim signals ready (up to ~3s under sandbox network "allowlist", + // the default) or its wait caps out. See sandbox.ts's shimScript. + if (attempt >= 500) throw new Error("Live command did not enter the compute ledger") await Bun.sleep(10) return find(attempt + 1) } diff --git a/docs/adr/0002-sandbox-network-policy.md b/docs/adr/0002-sandbox-network-policy.md new file mode 100644 index 00000000..0428c90f --- /dev/null +++ b/docs/adr/0002-sandbox-network-policy.md @@ -0,0 +1,73 @@ +# ADR 0002: Sandbox network policy becomes three-state, allowlist by default + +Status: accepted + +## Context + +`sandbox.network` is `"allow" | "deny"`. Deny locks kernels out of PyPI, NCBI, UniProt, PDB and +EBI, which is most of what a research tool is for. Allow is unrestricted egress. Neither state is +what the product needs: a kernel that cannot reach a package index or a sequence database cannot +do the work it exists to do, and a kernel with unrestricted egress can send agent-controlled data +anywhere on the internet. Because the installer needed the egress the kernel was denied, earlier +design work gave it a second, network-enabled sandbox purely so it could have that egress the +kernel could not. + +## Decision + +`sandbox.network` becomes three-state: `"deny" | "allowlist" | "allow"`, defaulting to +`"allowlist"`. + +Enforcement is `--unshare-net` plus a bind-mounted unix socket, established by two measurements +taken on the spike branch `proto/sandbox-allowlist-proxy` before the proxy itself was written: +inside `bwrap --unshare-net`, TCP to any host — including the host's own loopback — returns `000`, +and a unix socket bind-mounted into that same network namespace still crosses it. The socket is +therefore the only route out of the namespace. The proxy on the host end of that socket resolves +names itself, which is why the sandboxed process has no DNS of its own. + +One policy covers kernel and installer. There is no separate network-enabled install sandbox: +under `"allowlist"` the installer reaches the same allowlisted hosts through the same proxy the +kernel uses, so the asymmetry that motivated a second sandbox no longer exists. + +Proxy policy is not part of the `ExecutionAuthority.generation` hash. `generation` hashes trust, +filesystem grants, and sandbox policy, and changing it tears down and reboots every live kernel +bound to it. Editing the allowlist is not that kind of change: it takes effect on the next +connection through the running proxy, without tearing down live kernels. + +This ADR decides the default allowlist ships in code; per-project additions live in config. + +## Consequences + +This is a breaking change to a documented config key. Existing `"deny"` and `"allow"` values keep +working unchanged; only the default moves, from `"deny"` to `"allowlist"`. + +`HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY` join `SAFE_ENV_PREFIXES` so they reach a kernel +process, and `Sandbox.wrapArgv` must compose a shim into the sandboxed argv, because pip, +requests and curl take an `http://host:port` proxy from those variables and none of them speak +unix sockets directly. Some process also has to start and stop the proxy across the CLI's own +lifecycle; this ADR does not fix that shape here, only that it is needed. + +The boundary is host-level, not content-level. The proxy pipes bytes after checking the +authority; it cannot see inside TLS, so an allowlisted host can still be sent anything a client +sends it. Allowlisting bounds where a kernel can talk, not what it says once it is talking. + +Unresolved: seatbelt has no namespace, so the _mechanism_ above — sever the network device, cross +back in only through a bind-mounted socket — does not transfer as written. That is not the same as +saying macOS cannot reach the same bounded-egress outcome: seatbelt can restrict +`network-outbound` to a specific local port via `(allow network-outbound (remote tcp +"localhost:PORT"))`, which is exactly the shape `anthropic-experimental/sandbox-runtime` ships (a +default network deny, then a selective allow for `network-bind`/`network-inbound`/ +`network-outbound` on the proxy's loopback port). Neither OS can filter by hostname at the +sandbox-profile level — that is what the proxy is for on Linux too — so this is achievable via a +different mechanism, not impossible. Task 7 built it: the host-side proxy listens on a loopback +TCP port directly on macOS (no bind-mounted socket, no bridge — seatbelt has no namespace to put +either behind), the profile permits `network-bind`/`network-inbound`/`network-outbound` on +exactly that port as described above, and — because a loopback port, unlike a unix socket, carries +no filesystem permissions of its own — every request to it must additionally carry a +`Proxy-Authorization` secret generated fresh per proxy start. This is unverified in the same sense +the rest of this ADR's Linux side was before it was measured: nobody on this project has run +`sandbox-exec`, so whether the profile text above is actually _accepted and enforced_ as written — +including whether `network-bind`/`network-inbound` are the right operations to permit at all, and +whether `(local ...)` is the right filter for them — is a real, open question, not merely +theoretical caution. See `.superpowers/sdd/2026-08-09-sandbox-network-policy/task-7-report.md` for +exactly what a Mac owner still needs to run to close it. Windows has no sandbox backend at all, so +the question does not apply there. diff --git a/docs/specs/kernel-execution-design.md b/docs/specs/kernel-execution-design.md new file mode 100644 index 00000000..45fbd417 --- /dev/null +++ b/docs/specs/kernel-execution-design.md @@ -0,0 +1,883 @@ +# Kernel execution and environments — design + +Status: draft, ready for review +Date: 2026-08-08 +Branch: `proto/kernel-package-install` + +## Problem + +A kernel cannot install a package. Three independent causes, each verified on an Arch box during +design, none of which the user can distinguish from the others: + +``` +$ which pip3 → not found +$ python3 -m pip --version → No module named pip +$ python3 -c "import site; ..." → /usr/lib/python3.14/site-packages writable=False +$ bwrap ... --unshare-net → sandbox denies the agent shell all network +``` + +The host interpreter may ship without pip (Arch does), the sandbox denies network, and +site-packages is read-only under `--ro-bind / /`. `findPython` (`tool/notebook.ts:201`) probes only +for a working `--version`, so it happily boots an interpreter that cannot install anything, and the +failure surfaces as an opaque error that reads like a broken machine. + +Two adjacent gaps found while investigating: + +- **GPU is unreachable.** `--dev /dev` mounts a fresh minimal devtmpfs, so no `/dev/nvidia*` reaches + the kernel. Verified: `nvidia-smi -L` inside the kernel's exact sandbox reports it cannot talk to + the driver. Meanwhile `KernelStatus.resources` declares `gpu_percent` and `vram_bytes` + (`science/kernel/registry.ts:99-100`) and `KernelCard.tsx:98-99` renders both — with **no sampler + anywhere**, so every card shows "Unavailable" permanently. +- **`package_install` already exists as a capability** in `project/trust.ts:25` and + `project/execution.ts:26`, with zero call sites. The slot was reserved and never filled. + +## Governing principle + +**Nothing is gated more strictly than arbitrary code execution unless it costs money.** + +`tool/notebook.ts:590` runs arbitrary agent-authored Python in a persistent kernel and asks for +`permission: "bash"` with `always: ["python*"]` — a standing grant covering all future execution. +`bash.ts` is the same shape. `tool/modal.ts` is stricter (exact-plan digest, `always: []`, plus a +`spendFilter` strip at `permission/next.ts:165-171`) for exactly one reason, stated in the comment +there: **paid actions**. + +An earlier draft of this design copied modal's contract. That was wrong — it would have made +installing a library stricter than running arbitrary code. Package installation costs nothing, so it +uses the ordinary contract. + +## Current architecture + +Five layers, unchanged by this spec except where noted. + +``` +NotebookTool / RKernelTool agent-facing tool, permission gate + ↓ +KernelRuntime (registry.ts) identity, persistence, provenance, authority + ↓ +KernelManager (per language) process pool, idle reaping + ↓ +Kernel (PythonKernel/RKernel) one child process, queue, wire protocol + ↓ +Sandbox.wrapArgv bwrap / seatbelt confinement +``` + +Facts this design relies on: + +- `KernelIdentity` is `{projectID, sessionID, name, language}`, hashed into a storage key at + `registry.ts:135`. Arbitrary names are already supported end to end — `POST /kernels` takes one, + provenance strips a `notebook:` prefix, the frontend strips it for display. +- `ExecutionAuthority.generation` (`project/execution.ts:95`) hashes trust, filesystem grants, and + sandbox policy. A change tears down and reboots live kernels at `registry.ts:337`. It takes + `{projectID?, sessionID, capability}` — **no kernel identity**, which constrains where env state + can live (see below). +- `KernelProcessIdentity` (`science/kernel/process.ts`) captures pid + a platform start token and + verifies both, guarding against pid reuse. + +## Decisions + +### Permission contract + +| | Decision | +| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Execution | Unchanged. `permission: "bash"`, `always: ["python*"]`. | +| Install | `permission: "package_install"` — the capability already declared in `trust.ts` and `execution.ts`. | +| Pattern | A canonical command string: `install numpy pandas → default [pypi.org/simple]`. This is both what the card shows and what the permission system matches. | +| `always` | `["install*"]` — a standing grant offered on the card from the start. Mirrors `notebook.ts:590`, which shows the specific `"python (notebook)"` and stores the broad `"python*"`. | +| Card fires | **On every install.** Allow-once approves that call and the next identical request prompts again; taking the broader grant runs subsequent installs without prompting. Friction is solved by the standing grant, not by exempting classes of install — no threshold to tune, and nothing enters an environment the user never saw once. | +| No digest | The command string does the job. Change the env, packages, or index and it is a different string, so the prompt reappears for free — and unlike a sha256 it is readable on the card. | +| No `spendFilter` entry | Installs are not a paid action. | + +### Environments + +| | Decision | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Shape | First-class, named, **language-scoped**. A python env and an r env are separate objects sharing one interface — mirrors `KernelManager`: one contract, per-language backends. | +| Conda | Off the table. Claude-science gets language-neutral envs free because conda unifies python and R in one directory; venv/uv unifies nothing, so neutrality would buy conda's abstraction without conda. | +| Manifest | The source of truth. `Global.Path.data/envs//.json`. | +| Directory | Derived, therefore cache. `Global.Path.cache/envs///`. | +| Kernel binding | A **property** of the registry entry, never part of `KernelIdentity` — adding it to the tuple rekeys every persisted record and orphans them. | +| Staleness | Compared at the **registry** level, not inside `ExecutionAuthority`, whose signature carries no kernel identity. | +| Default | A new kernel binds to `default` unless told otherwise. **The binding point is the tool, not the route** — `POST /kernels` was removed in #274/#275 and the agent now names kernels through the `kernel` parameter on `notebook`/`rkernel`, so `environment` belongs beside it, exactly as the reference carries `environment=` on every `python`/`bash`/`r` call. Reassignment is explicit, because it restarts the kernel. | +| Creation | No approval card. It writes a directory in our own cache and runs stdlib code. The install card notes the env will be created. A `uv venv --python X` that downloads an interpreter adds a line to that card. | + +Kernel reads are free — the cache directory is readable under `--ro-bind / /`. Only the installer +needs a writable bind. + +### Installer + +**Ladder**, probed in order: + +1. Existing env directory → use it +2. `Bun.which("uv")` → uv +3. `python3 -m venv` + ensurepip +4. Neither → fail with the exact remedy (`python3-venv` on Debian/Ubuntu, or install uv) + +Verified: on a host whose `python3` has no pip, `python3 -m venv` still bootstraps pip 26.1.2 from +`/usr/lib/python3.14/ensurepip/_bundled/pip-26.1.2-py3-none-any.whl`, and it works **offline inside +`--unshare-net`**. uv is a fast path, never a requirement. Only the Debian/Ubuntu ensurepip split is +a genuine dead end. + +Never auto-download uv. `compute/modal/volume.ts:112-116` is the house precedent: probe, use if +present, throw a remedy if not. + +**Containment.** _Superseded — see "Network policy" below._ Earlier drafts specified a **separate** +install sandbox, network-enabled, because the kernel's was network-denied and only the installer +needed egress. The allowlist proxy removes that asymmetry: under one policy the kernel can reach +PyPI too, so there is no second sandbox. The install runs in the **same** sandbox as the kernel, +differing only in what is writable: + +- egress via the allowlist proxy — identical to the kernel, not a relaxation +- writes confined to the env directory and a private `TMPDIR` +- a **writable package cache** bound in — without it pip disables its cache and every retry and + rebuild re-downloads +- `kernelSensitivePaths()` masked to `/dev/null` +- `--unshare-pid` + +Verified twice. A C-extension source build (`markupsafe --no-binary :all:`) completes inside this +sandbox, the compiler and headers arriving free on `--ro-bind / /`, with the credential mask holding +(`Permission denied` inside, real contents outside). And `pip install --only-binary :all: tqdm` +completes through the proxy with the same masks in place. An earlier claim that source builds would +break was reasoning, not evidence, and was wrong. + +**Wheels-only (`--only-binary :all:`) is the default**, escalating to source builds on request. This +is a speed and reliability default, _not_ a security boundary — if bwrap contains agent Python at +import time it contains `setup.py` at install time. + +### Install lifecycle + +| | Decision | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Resolution | Approve the **request**; resolve versions after. The card shows unversioned names. | +| Already satisfied | Skip outright — no card, no install, no restart. Nothing privileged happens, so nothing needs approving. | +| Dispatch | **`wait: true` by default** — the install runs inline and returns its result, so a two-second install costs one turn. `wait: false` returns an exec id to poll, mirroring `modal.ts` → `compute_job`. No notification channel. | +| Verification | After a successful install, import the installed names in the target env and report the versions. Catches an installer that exits 0 without producing a working module. | +| Busy kernel | **Queue** behind the running cell, display `queued behind `, offer a cancel. A cell that lazily imports a submodule mid-install can load a half-written file, so this is correctness, not scheduling. | +| Lock | **Per-env.** Other environments stay fully usable. | +| Restart | **Conditional on the change set** — see "Reopened by later evidence". Purely additive → no restart, namespace survives. Any removal, downgrade, or version change → restart every kernel bound to that env. Kernels on other envs are always untouched. Shared envs are enforced additive-only, so they never restart. | +| Reported failure | **Nothing landed.** Modern pip builds every wheel before running the install phase, so a build failure aborts before anything is committed — verified: a failing package's cleanly-resolving dependency was downloaded and still not installed. Report the log and stop; there is no subset to keep or retry. | +| Failure diagnosis | Surface the **cause**, not pip's summary line. `ERROR: Failed building wheel for X` names the package; the `fatal error:` line above it names a missing system header, which usually means the install is unachievable in a sandbox and a pure-Python alternative is the answer. | +| Wheels-only rejection | Translate it. `Could not find a version that satisfies the requirement X (from versions: none)` reads as "no such package" but means "no wheel for this policy". Say that, and offer the source-build escalation. | +| Interruption | Detach the installer (`detached: true`, no `--die-with-parent`) and **persist the lock with pid + start token**. On CLI start, reconcile: pid alive and token matches → still running; otherwise → unknown outcome. | +| Unknown outcome | New env → `rm -rf`. Existing env → mark dirty, rebuild from manifest. | +| No snapshots | pip has no transactions, and `cp --reflink=always` fails on ext4 here — a pre-install snapshot is a full multi-GB byte copy. Rollback is env-level only. | +| Constraints | Parse with a real PEP 508 parser. Splitting on `==` mishandles `numpy>=2.4`, extras, and markers. | +| Index credentials | Strip before matching, redact on the card. They are env config, not part of the approved action. | + +### Agent contract + +`PackagePrompt.system()` (written, `src/package/prompt.ts`) injected unconditionally into the system +array at `session/prompt.ts:863`, exactly as `SystemPrompt.compute()` is today. + +This is the mechanism that scales. 199 of 293 `SKILL.md` files mention `pip install`; 435 files do. +Editing them is neither necessary nor sufficient — the block pre-empts all of them, plus reference +files the skill tool never intercepts and third-party skills cloned from GitHub that this repo +cannot edit. + +**No skill-level override.** `ComputePrompt.skill()` is a whole-document replacement, appropriate +only when the whole document is wrong — true of the modal skills, whose subject _is_ the governed +mechanism. Every package-mentioning skill is about a domain (docking, geopandas, pydicom) with +install as scaffolding; replacing them would destroy correct content to fix a preamble. Add an +override only if a skill appears whose subject is environment setup. None exists today. + +**Refusal, not redirect — revised after the proxy landed.** This decision previously read "a shell +`pip install` must fail with a message naming `package_install`, not a DNS error", and rested on a +premise that is no longer true: that shell installs fail anyway, so the only job was replacing a +confusing error with a helpful one. + +Under `network: "allowlist"` they succeed. Measured on this branch, inside the agent's own sandbox, +with no tool and no approval card: + +``` +python3 -m venv /venv && /venv/bin/pip install tqdm → 4.70.0 +``` + +The workspace is writable and pypi is allowlisted, so system site-packages being read-only stops +nothing — the agent just builds its own venv beside the project. The proxy did not create the +intent to bypass; it removed the accident that used to prevent it. + +So the shell path has to be **refused**, not merely redirected, or the approval card is decorative: +an agent that never calls `package_install` never shows one. Requirements: + +- Refuse in the bash tool, before execution, matching the installer invocations named in + `PackagePrompt` — including `/bin/pip`, `python -m pip`, and `uv pip`, which a bare + `pip install` match misses. `tree-sitter-bash` is already a dependency and already used for + command parsing, so this is a parse, not a regex over the command line. +- Fail with the same message the contract uses, naming `package_install`. The redirect's original + value stands; it is now the message attached to a refusal rather than to a failure. +- Do **not** solve this by removing pypi from the allowlist. Notebook cells legitimately fetch from + allowlisted hosts, and an allowlist that differs per tool is a second policy to keep consistent. +- Refusal is a contract boundary, not a security boundary. An agent determined to bypass it can + vendor a wheel by hand over the same allowlisted egress. What refusal buys is that the _normal_ + path — the one every skill's `pip install` line leads to — arrives at the card. Treat it as + governance, and do not claim more for it. + +### GPU + +| | Decision | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `sandbox.gpu` | `"none" \| "nvidia"`, default `"none"`. When set, `bubblewrapArgs` emits `--dev-bind-try` for the nvidia node set. | +| Not `allowWrite` | Verified: `--bind-try` mounts `nodev` and NVML reports Insufficient Permissions. Only `--dev-bind-try` works. No config-only workaround exists. | +| Authority | Reuses `kernel`. The policy already hashes into `generation`, so flipping it reboots live kernels under the new rules. | +| Metrics | `nvidia-smi --query-compute-apps=pid,used_gpu_memory` joins onto the process-group model in `metrics.ts`. Unavailable → omit the field, never 0. | +| Ordering | GPU access **before** metrics. With the flag off no kernel can be a CUDA app, so a sampler shipped first reads zero by construction. | + +Orthogonal to `--unshare-net`: GPU grants compute without touching the network boundary. + +## Reference implementation — Claude-science, as observed + +Recorded from screenshots and a transcript of a live session, not from documentation or source. It +is the closest shipping analogue to this design, and several decisions above are either copied from +it or deliberately diverge. Treated as evidence of what works in practice, not as a specification. + +### The sequence it runs + +The user typed a direct imperative — _"Can you pip install numpy"_ — and it **did not install**. + +1. **Probe first**, through `bash`. Two probe commands were captured verbatim; the scipy one is + known only by its reported result: + ``` + python -c "import numpy; print('numpy', numpy.__version__)" → numpy 2.4.6 + ‹scipy probe — command not captured› → scipy 1.17.1 + python -c "import torch; print('torch', torch.__version__, '| cuda available:', + torch.cuda.is_available())" 2>&1 || echo "NOT INSTALLED" + ``` +2. **Report and stop** when satisfied: _"numpy is already installed in the default python + environment — version 2.4.6 — so there's nothing to install."_ +3. **Volunteer the ambiguity unprompted** — a different project environment, or inside a Modal + container image, are named as separate targets with different answers. +4. **Reason about placement out loud** before acting, only for the absent package: _"It's a + heavyweight stack, and platform convention is to keep those out of the shared default env, so + I'll put it in a dedicated environment rather than the default one."_ +5. **Reason about hardware**: _"the local GPU isn't accessible from this sandbox, so a local install + would be CPU-only torch (useful for development/testing; actual GPU training goes through + Modal)."_ It then selects `pytorch-cpu` because of that, rather than installing the GPU build and + failing later. +6. **Only then** emit the typed call. + +### The call + +``` +mode create +name torch-cpu +python_version 3.13 +packages › 2 items [pytorch-cpu, torchvision-cpu] +channels › 1 item [pytorch] +background true +``` + +Typed parameters, not a shell string. The approval card renders them as a compact command line +rather than raw JSON: + +``` +create torch-cpu pytorch-cpu torchvision-cpu [channels: pytorch] [python=3.13] +``` + +Package names appear **unversioned**. No pins, no resolved set, no download size. + +### A second trace — installing into an existing environment + +Header: _"Ran 2 commands, set up an environment · 3 steps"_. + +**Step 1 — batched probe**, one `bash` call, `ENV python`: + +``` +python -c "import PIL, sys; print('pillow', PIL.__version__)" 2>&1 | tail -1; \ +python -c "import tqdm; print('tqdm', tqdm.__version__)" 2>&1 | tail -1; \ +pip index versions tdm 2>&1 | head -3 +``` + +``` +pillow 12.3.0 +ModuleNotFoundError: No module named 'tqdm' +tdm (0.1.0) +Available versions: 0.1.0 +``` + +Several probes chained with `;`, each normalised with `2>&1 | tail -1` so a missing module returns a +one-line result rather than a traceback. + +Note the third command: `tdm`, not `tqdm`. The typo resolved to a **real, unrelated package** and +returned its versions. Nothing was installed from it and the agent went on to install `tqdm` +correctly — but it is a live instance of the typosquat exposure that motivates sandboxing the +installer. + +**Step 2 — the install call:** + +``` +environment python +mode install +packages › 1 item [tqdm] +use_pip true +``` + +``` +Installed via pip in 'python': tqdm +``` + +**Step 3 — post-install verification**, again through `bash`: + +``` +python -c "import tqdm, PIL; print('tqdm', tqdm.__version__); print('pillow', PIL.__version__)" +→ tqdm 4.70.0 + pillow 12.3.0 +``` + +What this establishes that the first trace did not: + +- **One tool, several modes.** `mode: create` and `mode: install` are the same tool. The first trace + created a conda env with `channels` and `python_version`; this one installs with `use_pip: true` + and no channels. That corroborates the two backends named in the lock message — **path-venv** and + **conda-backed** — from a second, independent direction. +- **The installer backend is an explicit parameter**, not an internal choice. `use_pip: true` is on + the call. +- **Install is synchronous by default.** No `background: true`, no `exec_id`, no notification — just + a terse one-line result. Background is opt-in per call, so a two-second install stays inline and + only long ones go async. +- **The default environment is named `python`.** Not "default". Weak but real evidence toward + language-scoped naming. +- **Their agent shell has pip and network.** `pip index versions` reached the index from `bash`. + So probe-first is a **policy choice there, not something a sandbox forces** — unlike here, where + the shell has neither. +- **It verifies after installing** rather than trusting the installer's report. + +**Not established by this trace:** no approval cards appear in it. Either none were shown, or an +earlier "Allow for chat" on `bash` covered steps 1 and 3 and the install card was not captured. The +absence is not evidence that installs go unprompted. + +### Approval cards + +One card of each kind was captured. What is on them: + +| | Probe | Mutate | +| -------------- | ----------------------------------- | ------------------------------------------------------- | +| Title | "Run a shell command?" | "Create conda environment torch-cpu?" | +| Chips | `python` · `conda env` | none visible | +| Body | the code, under a `Code` disclosure | the rendered command line, under a `Details` disclosure | +| Primary button | **Allow for chat** | **Allow once** | +| Also | dropdown chevron, `Deny` | dropdown chevron, `Deny` | + +**Not established:** whether "read-only persists, mutation does not" is a systematic policy. That is +one sample of each, and both buttons carry a dropdown — so the label shown is the default offered, +not necessarily the only scope available. The pairing is suggestive and matches how +`permission/next.ts` already separates paid from ordinary actions, which is why this spec adopts the +shape; it is not evidence that they enforce it. + +### The dispatch response + +```json +{ "status": "running", "exec_id": "94d34ecc-f441-4dd8-ab8e-00702bbee577", "message": "…" } +``` + +The message is the interesting artefact. Decomposed: + +- **Async by default** for environment operations, returning immediately. +- **Permanent placeholder** — _"this placeholder is permanent"_. The tool result in the transcript + never updates; the outcome arrives later as a `notifications[]` entry of type `cell_result`, via + an explicit `wait_for_notification` or automatically at the start of a later turn. +- **No progress streaming** — _"Progress streaming (`exec_peek`) is not available for + package/environment operations."_ Stated rather than faked. +- **Honestly leaky interrupt** — `host.exec_interrupt(exec_id)` gives _"real termination for a + path-venv; for a conda-backed environment the wait is abandoned — lock released, subprocess + continues detached."_ It tells the agent that cancel does not always cancel. +- **Per-environment lock, stated to the agent** — \_"do NOT run python, r, or `manage\__` in that + environment until it finishes (its packages are being rewritten and **its kernel restarts on + completion**). A different environment or bash is fine."\* + +Two backends are named in that one sentence: **path-venv** and **conda-backed**. + +`python, r, manage_*` also appear together under one environment's lock. Two readings fit equally +well: the environment genuinely hosts both languages (conda can), or the message is a generic +template listing every execution tool regardless of what this environment contains. **The +screenshots do not distinguish them**, and an earlier draft of this spec asserted the first as fact. + +The language-scoped decision above does not rest on this either way — a venv cannot host R, which is +reason enough on its own. + +### A third trace — the network model + +Asked whether its sandbox has network, it reported: + +> _"Yes — the sandbox has network access, but it's filtered through an **allowlist proxy** rather +> than being open."_ + +- **Reachable (200):** `pypi.org`, `eutils.ncbi.nlm.nih.gov`, `rest.uniprot.org` +- **Blocked at the proxy:** `example.com`, `www.google.com` — connection fails outright +- **Mechanics:** all outbound goes through an HTTP/HTTPS proxy, `*_proxy` env vars set. Direct DNS + resolution returns nothing — name resolution happens _at the proxy_, so `getent hosts` is empty + even for domains that work. Connectivity must be tested over HTTP, never ping or DNS. +- **Allowlisted classes:** scientific APIs and package registries — NCBI, Ensembl, UniProt, PDB, + EBI, ChEMBL, arXiv, CRAN/Bioconductor, PyPI, conda, npm. Arbitrary browsing is not. +- **Adding a domain:** _"approval takes effect immediately without losing kernel state."_ + +This is categorically different from `--unshare-net`. Ours is binary — the kernel has all network or +none. Theirs is a **bounded** network: the kernel can reach the registries and data sources a +research tool actually needs, and cannot reach anywhere else. Because the policy lives in a proxy +rather than a namespace, changing it does not restart anything. + +### A fourth trace — the four install routes + +Asked how it installs packages, it enumerated: + +**1. `manage_packages` — the durable path.** Writes into the environment's real site-packages and +survives kernel restarts. conda by default, pip with `use_pip=true`. Accepts version pins, and _in +dedicated envs_ git URLs, wheel URLs, and extras. Also `mode="uninstall"` and `mode="list"`. + +> _"Installing does not restart the kernel — your variables and imports survive, and a new package +> is importable immediately. **Uninstalling does restart it.**"_ + +And the constraint that makes that safe: + +> _"the shared default `python` and `r` envs are **additive-only**. They accept bare names with at +> most an exact `==` pin; URLs, VCS refs, and version ranges are rejected, and uninstall is blocked. +> Conda there also runs `--freeze-installed`, so an install can never disturb what's already +> present."_ + +**2. `manage_environments` — a dedicated env**, for anything that may later need removing or +re-pinning, and for heavyweight stacks: + +``` +manage_environments(mode="create", name="cheminfo", packages=["rdkit", "scikit-learn"]) +``` + +Every subsequent `python`/`bash`/`r` call then carries `environment="cheminfo"`. Environments +present on that box: **`python`, `r`, `torch-cpu`, `compute-provider-modal`**. + +It can also **register an existing venv from a granted host path**, working against the user's own +repo interpreter with editable installs rather than a managed copy. + +**3. `pip install` inside a bash or python cell — ephemeral.** Gone when the kernel shuts down. And +worse in managed conda envs, where _"their site-packages are mounted read-only in the sandbox, so +`/bin/pip install` reports success and writes nothing."_ A silent no-op, not an error. + +**4. Remote and provider-side** — baked into the job's image on that side. + +Stated default: `manage_packages` into a purpose-built env, reserving the shared `python` env for +small additive things like `tqdm`. + +### What these two traces settle + +- **Environments are language-scoped.** `python` and `r` are _separate environments_ on the same + box. The earlier lock message naming `python, r, manage_*` together was a generic template listing + execution tools, exactly as the alternative reading suggested — not evidence of neutrality. The + hedge in this spec was correct and the question is now closed, in favour of the choice made here. +- **Adopting a user's existing venv is a real, shipped capability**, not a hypothetical. It was + listed as an open product question in this design; the reference answers it. + +### A fifth trace — two failure surfaces, neither partial + +**Blocked before any build.** `pyaudio` into the shared `python` env returned a `manage_packages` +error, not a compiler one: + +``` +ERROR: Could not find a version that satisfies the requirement pyaudio (from versions: none) +``` + +The shared env is **wheel-only as well as additive-only**, so an sdist-only package is filtered out +of the candidate list and never reaches a build step. Nothing downloaded, nothing changed. + +Note the message. `from versions: none` reads as _"this package does not exist"_ when it means +_"no wheel available for this policy"_. Under a wheels-only default that error will be common, and +raw is the wrong way to surface it. + +**A real build failure**, reproduced in a throwaway venv with a C extension against a nonexistent +header: + +``` +Building wheel for brokenpkg (pyproject.toml): finished with status 'error' + src/speed.c:2:10: fatal error: portaudio_that_does_not_exist.h: No such file or directory + error: Command '['gcc', ...]' returned non-zero exit status 1 + note: This error originates from a subprocess, and is likely not a problem with pip +ERROR: Failed building wheel for brokenpkg +``` + +Read bottom-up: pip's own `ERROR:` names only _which_ package failed; the cause is the `fatal error:` +line, and it is a missing **system** header, not a Python dependency. That signature means an +OS-level `-dev` package is required, which in a sandbox usually means the install is not achievable +and a pure-Python alternative is the answer. + +**The install was atomic.** `brokenpkg` was given a dependency on `six`, which installs cleanly as a +wheel. The log shows `Collecting six` — resolved and downloaded — yet: + +| | before | after | +| --------- | --------------------------------- | ----------- | +| installed | packaging, pip, setuptools, wheel | _identical_ | + +`six` was not left behind. Modern pip builds every wheel first and runs the install phase only after +all builds succeed, so a build failure aborts before anything is committed. The outcome is a clean +environment plus a log, never a half-installed one. + +**Where partial state does arise**, per the same trace: + +- a package that builds and installs fine but fails on **import** — wrong ABI, missing runtime `.so` +- a legacy `setup.py install` invoked directly +- an **interrupted** multi-package install, where earlier wheels already landed — and on conda, + `host.exec_interrupt` abandons the wait while the operation continues detached + +### A sixth trace — `mode="list"`, and the provider environment + +**`list` returns structured data, not text:** + +``` +{ environment_name, package_count, packages: [...], python_version, history: [...] } +``` + +`packages` is `name==version` for **everything the solver knows about**. The shared `python` env +reports 168 entries, most of them native libraries and fonts — `libgcc`, `harfbuzz`, `xorg-libx11`, +`qt6-main` — with importable Python packages a minority. + +_This is a bug in `src/package/prompt.ts` as first written_, which rendered the full package list +into the capability block on every request. Fixed: the block now shows only what was explicitly +requested, plus a `(+N deps)` count. A contract buried in font libraries teaches the agent nothing. + +**`history` is the field that matters** — an ordered record of how the env was built: + +``` +create numpy, pandas<3, scipy, matplotlib, seaborn, pillow, socksio, pysocks (py3.11) +install pypdfium2==5.9.0 (pip) +install nbformat (conda) +install tqdm (pip) +``` + +Seed spec, then every mutation, each tagged with its backend — _"the fastest way to answer 'where +did this package come from, and was it conda or pip?', which matters because mixing the two in one +env is where dependency resolution tends to break."_ + +**This sharpens the manifest decision.** This spec says the manifest is the truth and the directory +is derived. A _flat package list_ cannot be replayed faithfully — order matters and backend matters. +An ordered, backend-tagged history can. Rebuild-from-manifest becomes replay-the-history, and the +same record answers the provenance question that was listed here as out of scope. + +**`compute-provider-modal`** is infrastructure, not a workspace: 59 packages, history is one line +(`create python=3.11, pip`), payload is `modal==1.5.1` plus its gRPC and async plumbing. Nothing +scientific. It backs a `compute_provider` tool — _"the authenticated kernel where the Modal SDK is +pre-imported and wired to your token"_ — used for provisioning: building images, managing volumes, +inspecting workspace state. It **explicitly rejects `gpu=`**; job submission goes through a separate +path entirely. + +That is a different boundary from the one in ADR-0001, which holds that credentials are _"never +added to a generic agent, shell, kernel, or job environment"_ and routes everything through a +trusted JS adapter. Theirs puts the credential **inside one dedicated, capability-restricted +kernel** that can provision but cannot dispatch paid work. + +Worth noting because of a cost we already pay for our version: `compute/modal/volume.ts` exists — +a pinned Python bridge launched through `uv` — solely because the JS SDK cannot read Volumes and we +refused to have a credentialed Python environment. A capability-restricted provider env would make +that bridge unnecessary. Not a recommendation to change ADR-0001; a recorded alternative with a +known price on both sides. + +### Isolation + +The web UI is served on `:8000` behind a **single-use nonce** that expires in three minutes; sandbox +content is served separately on `:8001`. Remote access requires forwarding both. + +### Mapping to this design + +| Observed | This spec | Why | +| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Probe before installing; report and stop when satisfied | **Adopted** — skip outright, no card, no restart | Confirms the principle independently | +| Unversioned names on the approval card | **Adopted** — approve the request, resolve after | Same trade | +| Read-only grant persists, mutation grant does not | **Adopted in shape**, on our own reasoning rather than theirs — the sample is one card each (see above) | Matches how `permission/next.ts` already separates paid from ordinary actions | +| A shell probe to answer "is numpy present?" | **Rejected** — the capability block carries the inventory, so nothing needs to run | Ours is a design choice about where inventory lives; why theirs probes is not observable | +| Typed parameters rendered as a command line on the card | **Adopted** — the canonical command string is both the display and the permission pattern | Readable, and it doubles as the match | +| Per-environment lock, named to the agent | **Adopted**, including surfacing it in the capability block | Directly copied | +| Kernel restarts on completion | **Adopted** | Confirms always-restart | +| Install is **synchronous by default**, `background: true` opt-in per call | **Adopted — revises this spec.** Default `wait: true`, returning the result inline; long installs pass `wait: false` and get an exec id to poll | An earlier draft made every install async, which is wrong for the `tqdm` case: two seconds of work behind a dispatch-and-poll round trip. `modal.ts` already has exactly this flag with exactly this default | +| Async dispatch with an exec id, when asked for | **Adopted** | — | +| Installer backend as an explicit call parameter (`use_pip: true`) | **Rejected** — the ladder picks it, and the choice is shown on the card | The agent has no basis for choosing; the host does. Exposing it invites the agent to pick badly and adds a field that changes nothing it can reason about | +| Verify by importing after installing | **Adopted** | Cheap, and it catches an installer that reports success without producing a working module | +| Batched probes in one shell call | **Not applicable** — the capability block carries the inventory, so there is nothing to probe | — | +| Notification delivery (`wait_for_notification`, `cell_result`) | **Rejected** — dispatch returns an id and is polled, mirroring `modal.ts` `wait: false` → `compute_job` | No notification channel exists here; polling already does | +| `python, r` under one environment lock — language-neutral, or a generic message template? | **Not adopted either way** — we are language-scoped | Decided on our own constraint: a venv cannot host R. Whether theirs is neutral is not established by the screenshots | +| Conda with channels | **Rejected** — venv/uv ladder | Heavy dependency and a second solver; the ladder is verified and needs neither | +| Channels on the approval card | **Adopted as `index`** | A channel is where the code comes from — the same reason index belongs in the pattern | +| Interrupt that abandons the wait and leaks a subprocess | **Rejected** — persist pid + start token and reconcile on startup | Copy the honesty, not the leak. `KernelProcessIdentity` already does exactly this for kernels | +| Combined create-and-install card | **Rejected** — no card for creating an empty env at all | Creating a directory in our own cache and running stdlib code is not privileged; a card that guards nothing devalues the ones that do | +| Separate origin for sandbox content, nonce on the app | **Deferred** — see Out of scope | Orthogonal; our `sandbox=""` iframe is stricter but blocks interactive output | +| No progress streaming for package operations | **Accepted as a constraint**, not a goal | If the closest analogue cannot do it, we should not promise it | + +## Reopened by later evidence + +Three decisions above predate the network and install-routes traces and are contradicted or +weakened by them. Flagged rather than silently rewritten, because two were explicit user calls. + +### 1. Restart policy — resolved: restart iff the change set is non-additive + +Initially read as a contradiction: the reference does not restart on install, only on uninstall. On +asking what happens when a **downgrade** lands in a dedicated env, where ranges are accepted and +`--freeze-installed` is not in force, the answer was that it still does not restart: + +> _"Install keeps the kernel alive, so you keep the stale module. Only `mode="uninstall"` restarts a +> kernel — and a downgrade goes through `mode="install"`, even though pip implements it internally +> as remove-then-reinstall. New files land in site-packages; your live interpreter never notices."_ + +So the reference has the hazard, and mitigates it with user discipline rather than mechanism. Its +own characterisation, from swapping a package's files under a running import: + +| | version | behaviour | +| --------------------------------------- | ------- | ------------ | +| before disk change | 2.0 | v2 | +| after disk change, no reload | 2.0 | v2 | +| submodule imported **after** the change | — | **v1** | +| after `importlib.reload` | 1.0 | v1 | +| name bound via `from … import compute` | — | **still v2** | + +**The hazard is mixed state, not staleness.** `sys.modules` caches only what was already imported. +Anything imported _later_ — a submodule, a lazy import inside a function, a dependency pulled in on +first use — reads the new files. The process ends up running 2.0's loaded modules beside 1.0's +freshly-loaded ones: a configuration neither version was tested in, failing in ways that do not +point back at the install. + +Two consequences worth carrying: + +- **`importlib.reload` is not a remedy.** It rebinds attributes on the module object, but names + bound directly (`from torch import foo`) still point at the old function, and live instances keep + their old classes. For compiled extensions it is worse — `torch._C` is a `.so` that CPython cannot + unload, so reload reuses the loaded one. _"A torch downgrade is not recoverable in-process."_ + Restart is the only recovery; never offer reload as an alternative. +- A bytecode-cache trap they hit: same-second mtime plus unchanged byte length let the `.pyc` be + considered valid, so the first reload returned the old version anyway. Real installs write fresh + sizes and mtimes, but hand-edited files will hit it. + +**This vindicates the always-restart call made here** — it eliminates by construction the hazard the +reference has to warn users about. But it overpays: a `tqdm` install discards a namespace for +nothing. + +The rule that dominates both: + +| Change set | Restart | +| --------------------------------------------------------------------- | --------------------------------------------------- | +| Purely additive — nothing existing replaced, removed, or re-versioned | **No.** No mix is possible, so nothing can go stale | +| Contains any removal, downgrade, or version change | **Yes**, unconditionally | +| Uninstall | **Yes** | + +Enforced additive-only on shared envs (their `--freeze-installed`) makes the common path _provably_ +additive, so it never restarts, by construction rather than by inspection. + +**Correction to an earlier draft of this table:** it said "restart only if a package being replaced +is already in `sys.modules`". That is wrong. A downgrade drags dependencies with it — if dependency +`B` was loaded as part of something else and the newly-installed `A` expects the older `B`, the mix +exists even though `A` itself was never imported. The trigger is the resolver's **whole change set** +containing anything non-additive, not an intersection with `sys.modules`. + +**Approval consequence:** because the restart is now conditional, the card must say so _before_ +approval — "this will replace numpy 2.3.4 with 2.2.0 and restart your kernel, discarding N +variables" — rather than the user discovering it afterwards. + +**And it must escape the standing grant.** A user who accepted `install*` to stop being asked about +`tqdm` has not consented to losing a namespace mid-session. A non-additive change is destructive, so +it prompts even when a standing allow is in force — the same carve-out `spendFilter` already +implements for a different reason at `permission/next.ts:167-171`, so the mechanism exists. +Recommended, not yet confirmed. + +### 2. Binary network policy — resolved, see "Network policy" + +Flagged here as a weakness, then prototyped and settled. The allowlist proxy is enforceable with no +root, it collapses the separate install sandbox, and it bounds exfiltration to a fixed host set +rather than the whole internet. Moved into its own section above with the measurements; this entry +remains only as the record of where the question came from. + +### 3. Keep-and-retry on partial install — premise withdrawn + +An explicit decision here was that a partial install keeps what landed and retries the failed subset +under the same approval, with a discard action for a twice-failed subset. The prototype carries an +`outstanding` field for it. + +**Build failures do not produce that state.** pip builds all wheels before installing any, so a +failed build commits nothing — demonstrated by a package whose clean wheel dependency was resolved +and downloaded and still absent afterwards. The premise was mine, not observed, and it was wrong. + +The three real sources of partial state are already covered by decisions taken for other reasons: + +| Source | Already handled by | +| ------------------------------------------------------- | ------------------------------------------------------------------ | +| Interrupted mid-install | Detach + reconcile; unknown outcome → rebuild from manifest | +| Installs, then fails on import (bad ABI, missing `.so`) | The post-install import verification adopted from the second trace | +| Legacy `setup.py install` | Wheels-only default | + +So `outstanding`, the retry-the-subset path, and the discard action have no remaining use case. +Recommend dropping all three rather than carrying machinery for a state pip does not produce. + +### 4. Silent-success on read-only site-packages + +_"`/bin/pip install` reports success and writes nothing."_ Our redirect-on-failure assumes a +shell install _fails_. Under a read-only mount it may exit 0 instead, which is worse than an error — +the agent believes it succeeded. The redirect must detect the no-op, not just the failure. + +## Re-analysed against `main` @ `74ee13cd` + +Three commits landed after this spec was drafted — #274 project-scoped inspector and kernel +lifecycle, #275 unified compute/results/artifact workflows, #276 minimised completed compute +results. `science/kernel/registry.ts` is **unchanged**, so the layer analysis above still holds. +Four things do move. + +### Named kernels became agent-driven + +`notebook` and `rkernel` now take `kernel` (a validated `[A-Za-z0-9][A-Za-z0-9._-]*` name, max 64) +and `action: "execute" | "stop"`. The tool description instructs the agent to issue several calls in +one response with distinct names for parallel analyses, to stop them when done, and — pointedly — +_"Never use shell subprocesses to imitate multiple kernels."_ A test asserts four named calls own +four live kernels concurrently. + +`POST /kernels` was **removed**. Kernels are created implicitly by naming one. + +Consequence for this spec: the environment binding must live on the **tool**, not the route. An +`environment` parameter beside `kernel`, which is exactly the shape the reference uses. Everything +else about binding — property not identity, registry-level generation, explicit reassignment — is +unaffected. + +### `CommandRuntime` is the tracking primitive we were about to build + +New at `science/command/registry.ts`, wired into `bash.ts`: every shell command registers with +`{id, projectID, sessionID, messageID, callID, description, command, process_id, started_at, +resources?}` and a `stop()` closure, and deregisters on exit. `list` / `owned(id, projectID, +sessionID)` / `stop` mirror `KernelRuntime` exactly. New routes `/commands` and +`/commands/:commandID/stop`. + +An install is a long-running command, so this is the live half of install-job tracking, already +built and already consistent with the rest of the codebase. Use it rather than inventing a parallel +registry. + +**But it is `new Map()` — in-memory only.** It does not survive a CLI restart, so the +detach-and-reconcile decision still needs its own persisted record with pid and start token. +`CommandRuntime` tracks what is running now; it cannot answer what was running before the crash. + +### Project trust flipped to trusted-by-default + +`ProjectTrust.status` inverted in #274: previously trusted only on an explicit persisted `trusted` +record, now trusted **unless** explicitly `revoked`. The tests were renamed to match — _"untrusted +project opens read-only…"_ became _"project code is enabled by default"_ — so this is deliberate, +not drift. + +Consequence: `canExecuteProjectCode` is true by default, so the `project_untrusted` branch of +`ExecutionAuthority.decide` is now rare. This spec should stop treating project trust as a +meaningful gate on installation. The real gates are the permission card, the sandbox, and +`sandbox_unavailable`. + +**One thing worth checking, not asserted.** The new condition is +`saved?.root !== canonical || saved.state !== "revoked"` → trusted. A record whose root no longer +matches evaluates the first clause true and yields _trusted_ even when its state is `revoked`. +Under the old code that case returned `revoked`. Whether a revoked project whose root moved should +re-trust silently is a question for whoever wrote #274; it may be intended, since a different root +is arguably a different project. Not tested here. + +## Network policy — supersedes the separate install sandbox + +`sandbox.network` is `"allow" | "deny"`. Deny (`--unshare-net`) is the default and locks kernels out +of PyPI, NCBI, UniProt, PDB and EBI — most of what a research tool is for. Allow is unrestricted +egress. Neither is what the product needs. + +A spike on `proto/sandbox-allowlist-proxy` established a third state, enforced rather than advisory: + +``` +--unshare-net → TCP to any host, incl. the host's own 127.0.0.1 000 blocked +bind-mounted unix socket, same namespace PONG crosses +``` + +The socket is therefore the only route out, and a proxy on the far end decides what is reachable, +resolving names itself. No root, no `pasta`, no `nftables`. + +``` +kernel ─TCP→ shim (127.0.0.1:3128, in-ns) ─unix socket→ proxy (host) ─→ allowlisted host only +``` + +Measured inside the sandbox: `pypi.org` 200, `eutils.ncbi.nlm.nih.gov` 200, +`rest.uniprot.org/uniprotkb/P00533.json` 200, `example.com` and `www.google.com` denied, direct +egress with the proxy unset 000, and `getent hosts pypi.org` empty — the reference implementation's +own signature, reproduced. Then `pip install --only-binary :all: tqdm` succeeded through it with +credentials masked. + +**Consequences for this spec:** + +- The **separate install sandbox is deleted**. One policy covers kernel and installer. +- `sandbox.network` becomes three-state: `"deny" | "allowlist" | "allow"`, defaulting to + `"allowlist"`. This is a **breaking change to a documented config key** and needs an ADR before + anything is built on it. +- Proxy policy must stay **out** of the `generation` hash, so adding a domain takes effect without + tearing down kernels — the property the reference advertises. +- `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` join `SAFE_ENV_PREFIXES`; nothing reaches a kernel today. +- `Sandbox.wrapArgv` must compose the shim (`sh -c ' & exec '`) and bind the socket. + +Not yet done in the spike: any product wiring, a config surface, per-project domains, macOS seatbelt, +audit logging, port policy on CONNECT. And the proxy pipes bytes after checking the authority — it +cannot see inside TLS, so host-level allowlisting is the boundary, not content inspection. + +## Sequencing + +Rewritten. The previous order predated the proxy spike, the rebase onto `74ee13cd`, and the +withdrawal of keep-and-retry; it was stale in four places. + +**Phase 0 — independent, ships now.** No open questions, no dependencies on anything below. + +1. `sandbox.gpu` flag emitting `--dev-bind-try` +2. GPU metrics sampler — **depends on 1**, since with the flag off no kernel can be a CUDA app +3. Three-way install diagnostics in `findPython` +4. Wire `PackagePrompt.system()` into the system array — harmless before the tool exists, because + what it says about shell installs is already true + +**Phase 1 — network policy.** Determines the shape of everything after it, so it goes first. + +5. ADR: `sandbox.network` three-state, and the credential-boundary question raised by + `compute-provider-modal` against ADR-0001 +6. Proxy and shim into `src/sandbox/`, `Sandbox.wrapArgv` composition, `*_proxy` in the env + allowlist, config surface, policy kept out of `generation` + +**Phase 2 — environments.** + +7. Environment store: ordered backend-tagged history as the manifest, directory under + `Global.Path.cache`, per-env lock +8. `environment` parameter on `notebook`/`rkernel` beside `kernel`; `findPython` prefers the env; + registry-level generation comparison + +**Phase 3 — installation.** + +9. `package_install` tool: canonical command-string pattern, `always: ["install*"]`, non-additive + changes escaping the standing grant +10. Installer ladder, wheels-only default, message translation for both failure surfaces +11. `wait: true` default with `wait: false` dispatch; `CommandRuntime` for the live half plus a + persisted pid + start token for reconcile-on-restart +12. Post-install import verification +13. R parity — `R_LIBS_USER` is already in the kernel env allowlist and `install.packages` always + exists, so this is the simpler backend + +## Not verified + +Stated so nobody builds on them: + +- **CUDA compute under `--dev-bind`.** `nvidia-smi -L` works; no kernel launch was tested. +- **macOS.** The seatbelt profile allows `file-write*` on all of `/dev` (`sandbox.ts:267`), so Metal + may already work — untested. No install-sandbox profile has been written for seatbelt. +- **Whether the capability block beats an install-heavy skill.** The worst case is + `chemistry/molecular-docking/SKILL.md` with 9 `pip install` mentions. Empirical, answerable only + by running it. The sandbox and the redirect are the backstop. +- **The proxy at any scale.** _Partly resolved on `feat/sandbox-network-policy`._ Backpressure, + bounded buffers, dial timeouts and a per-client state machine were built and measured after this + was written; an 18 MB wheel now arrives byte-exact through a real sandbox in CI on both backends. + What remains unverified is the original sentence's tail: **concurrency is still uncapped** + (~64.7 KB per connection, no ceiling), there is no audit log, and behaviour under a slow or + hostile upstream is untested. `pip install torch` at gigabyte scale still has not been run. +- **The proxy on macOS.** _Resolved._ Seatbelt has no namespace, so the design changed rather than + transferred: the proxy listens on `127.0.0.1:` and the profile narrows `network-outbound` to + that one address, with a per-start secret in the proxy URL because every process on the machine + shares one loopback. Verified against a real `sandbox-exec` in CI — allowlisted host 200, denied + host refused, no direct route, no DNS, 18 MB byte-exact, and `pip install` through the + authenticated proxy. +- **`sandbox.gpu` alongside the proxy.** Both change `wrapArgv`; they have never been composed. + Still true — `sandbox.gpu` does not exist yet. +- **The release-mode shim.** In a compiled binary the binary is its own shim + (`Installation.isLocal()` false). Verified once by hand against a `bun run build --single` build — + a real `pip install` succeeded inside `--unshare-net` with the boundary intact — but every + automated test runs under `bun run`, which takes the dev-bundle branch instead. Nothing keeps it + working. + +## Out of scope + +- **Interactive kernel output on a second origin.** `NotebookView.tsx:764` renders kernel + `text/html` in `