From 68e6edf7e3b20a44e6a849c76ea5e80e8ad88a3a Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Fri, 26 Jun 2026 15:18:29 +0200 Subject: [PATCH 1/2] Allowlist the Galaxy host in the bash sandbox when GALAXY_URL is scheme-less hostFromUrl fed the raw GALAXY_URL straight into new URL(), which throws on a bare host like "usegalaxy.org", so the Galaxy host never made it into the sandbox's network allowlist. Run it through normalizeGalaxyUrl first -- the same helper /connect and the profile system use -- so a scheme-less host resolves the same way the connection does, and the allowlisted host is exactly the one Galaxy talks to. While here, only keep the host for http(s) so an ftp:// or file://host/ URL can't seed the allowlist; Galaxy is http(s) only anyway. --- extensions/loom/sandbox/sandbox-config.ts | 11 +++++++++- tests/sandbox-config.test.ts | 26 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/extensions/loom/sandbox/sandbox-config.ts b/extensions/loom/sandbox/sandbox-config.ts index 8f303e8f..fd712680 100644 --- a/extensions/loom/sandbox/sandbox-config.ts +++ b/extensions/loom/sandbox/sandbox-config.ts @@ -1,5 +1,6 @@ import * as path from "path"; import type { SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime"; +import { normalizeGalaxyUrl } from "../profiles"; /** * Inputs for deriving the OS-sandbox profile. Pure: no I/O, fully testable. @@ -42,7 +43,15 @@ const DENY_WRITE = [".env", ".env.*", "*.pem", "*.key"]; export function hostFromUrl(url?: string): string | undefined { if (!url) return undefined; try { - return new URL(url).hostname || undefined; + // Normalize via the same helper /connect and the profile system use, so a + // scheme-less GALAXY_URL ("usegalaxy.org") still resolves to a host. + const parsed = new URL(normalizeGalaxyUrl(url)); + // Galaxy speaks http(s) only (validateGalaxyUrl enforces this on connect), + // so keep the host only for those schemes -- an ftp://x.org or + // file://host/... GALAXY_URL must not seed the bash network allowlist. The + // allowlisted host is therefore the Galaxy connection's, never broader. + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return undefined; + return parsed.hostname || undefined; } catch { return undefined; } diff --git a/tests/sandbox-config.test.ts b/tests/sandbox-config.test.ts index bd74418e..37b69eb6 100644 --- a/tests/sandbox-config.test.ts +++ b/tests/sandbox-config.test.ts @@ -9,6 +9,27 @@ describe("hostFromUrl", () => { expect(hostFromUrl(undefined)).toBeUndefined(); expect(hostFromUrl("not a url")).toBeUndefined(); }); + + it("extracts the host from a scheme-less URL (the form config/env often supply)", () => { + expect(hostFromUrl("usegalaxy.org")).toBe("usegalaxy.org"); + expect(hostFromUrl("test.galaxyproject.org/")).toBe("test.galaxyproject.org"); + expect(hostFromUrl("my.galaxy.example:8080/x")).toBe("my.galaxy.example"); + }); + + it("keeps a loopback http host (the one non-https scheme Galaxy permits)", () => { + expect(hostFromUrl("http://127.0.0.1:8080")).toBe("127.0.0.1"); + }); + + it("ignores non-http(s) schemes so they can't seed the network allowlist", () => { + // Galaxy speaks http(s); ftp:// / file:// (even with a host) must not allowlist one. + expect(hostFromUrl("ftp://x.org")).toBeUndefined(); + expect(hostFromUrl("file://evil.example/etc/passwd")).toBeUndefined(); + }); + + it("yields no host for empty or unparseable input", () => { + expect(hostFromUrl("")).toBeUndefined(); + expect(hostFromUrl(" ")).toBeUndefined(); + }); }); describe("buildSandboxConfig", () => { @@ -41,6 +62,11 @@ describe("buildSandboxConfig", () => { expect(c.network!.allowedDomains).toContain("usegalaxy.org"); }); + it("allowlists the Galaxy host even when the configured URL is scheme-less", () => { + const c = buildSandboxConfig({ ...base, galaxyUrl: "usegalaxy.org" }); + expect(c.network!.allowedDomains).toContain("usegalaxy.org"); + }); + it("includes extra write roots and extra allowed domains", () => { const c = buildSandboxConfig({ ...base, From e3e211eb5be20df8969543c3de973c3109a27621 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Fri, 26 Jun 2026 16:05:21 +0200 Subject: [PATCH 2/2] Tidy the #297 sandbox host tests and comment Fold the whitespace-only case into the existing junk test -- the empty-string assert just re-covered the !url guard that undefined already exercises -- and trim the protocol-gate comment down to the why. --- extensions/loom/sandbox/sandbox-config.ts | 5 ++--- tests/sandbox-config.test.ts | 6 +----- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/extensions/loom/sandbox/sandbox-config.ts b/extensions/loom/sandbox/sandbox-config.ts index fd712680..bface652 100644 --- a/extensions/loom/sandbox/sandbox-config.ts +++ b/extensions/loom/sandbox/sandbox-config.ts @@ -47,9 +47,8 @@ export function hostFromUrl(url?: string): string | undefined { // scheme-less GALAXY_URL ("usegalaxy.org") still resolves to a host. const parsed = new URL(normalizeGalaxyUrl(url)); // Galaxy speaks http(s) only (validateGalaxyUrl enforces this on connect), - // so keep the host only for those schemes -- an ftp://x.org or - // file://host/... GALAXY_URL must not seed the bash network allowlist. The - // allowlisted host is therefore the Galaxy connection's, never broader. + // so keep the host only for those schemes -- an ftp:// or file:// GALAXY_URL + // must not seed the bash network allowlist. if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return undefined; return parsed.hostname || undefined; } catch { diff --git a/tests/sandbox-config.test.ts b/tests/sandbox-config.test.ts index 37b69eb6..cf79c313 100644 --- a/tests/sandbox-config.test.ts +++ b/tests/sandbox-config.test.ts @@ -8,6 +8,7 @@ describe("hostFromUrl", () => { expect(hostFromUrl("https://my.galaxy.example:8080/x")).toBe("my.galaxy.example"); expect(hostFromUrl(undefined)).toBeUndefined(); expect(hostFromUrl("not a url")).toBeUndefined(); + expect(hostFromUrl(" ")).toBeUndefined(); }); it("extracts the host from a scheme-less URL (the form config/env often supply)", () => { @@ -25,11 +26,6 @@ describe("hostFromUrl", () => { expect(hostFromUrl("ftp://x.org")).toBeUndefined(); expect(hostFromUrl("file://evil.example/etc/passwd")).toBeUndefined(); }); - - it("yields no host for empty or unparseable input", () => { - expect(hostFromUrl("")).toBeUndefined(); - expect(hostFromUrl(" ")).toBeUndefined(); - }); }); describe("buildSandboxConfig", () => {