From 74dfba5b66a977409e52ff1de04eaefb71ebf258 Mon Sep 17 00:00:00 2001 From: Cooper Date: Mon, 9 Mar 2026 14:53:48 -0700 Subject: [PATCH 1/2] Networking Controls --- lib/resources/abstraction/sandbox.ts | 45 ++++++++- lib/resources/abstraction/stub.ts | 14 +++ lib/types/pod.ts | 12 +++ lib/types/stub.ts | 2 + tests/sandbox-network.test.ts | 143 +++++++++++++++++++++++++++ 5 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 tests/sandbox-network.test.ts diff --git a/lib/resources/abstraction/sandbox.ts b/lib/resources/abstraction/sandbox.ts index c4be9cb..d8eff78 100644 --- a/lib/resources/abstraction/sandbox.ts +++ b/lib/resources/abstraction/sandbox.ts @@ -7,6 +7,7 @@ import type { PodSandboxCreateImageFromFilesystemResponse, PodSandboxUpdateTtlResponse, PodSandboxExposePortResponse, + PodSandboxUpdateNetworkPermissionsResponse, PodSandboxExecResponse, PodSandboxListFilesResponse, PodSandboxCreateDirectoryResponse, @@ -51,6 +52,10 @@ function shellQuote(arg: string): string { * for sandboxes that never timeout. * - volumes (Volume[]): The volumes and/or cloud buckets to mount into the sandbox container. * - secrets (SecretVar[]): Secrets to pass to the sandbox, e.g. [{ name: "API_KEY" }]. + * - blockNetwork (boolean): Whether to block all outbound network access. Cannot be combined with + * `allowList`. + * - allowList (string[]): CIDR ranges that are allowed for outbound network access. When specified, + * all other outbound traffic is blocked. * - authorized (boolean): Ignored for sandboxes (forced to false). */ export class Sandbox extends Pod { @@ -373,9 +378,37 @@ export class SandboxInstance extends PodInstance { } /** - * List all exposed URLs in the sandbox. + * Dynamically update outbound network permissions for the sandbox. */ - public async listUrls(): Promise { + public async updateNetworkPermissions( + blockNetwork: boolean = false, + allowList?: string[] + ): Promise { + if (blockNetwork && allowList && allowList.length > 0) { + throw new Error("Cannot specify both blockNetwork=true and allowList"); + } + + const resp = await beamClient.request({ + method: "POST", + url: `/api/v1/gateway/pods/${this.containerId}/network/update`, + data: { + stubId: this.stubId, + blockNetwork, + allowList: allowList ?? [], + }, + }); + const data = resp.data as PodSandboxUpdateNetworkPermissionsResponse; + if (!data.ok) { + throw new SandboxProcessError( + data.errorMsg || "Failed to update network permissions" + ); + } + } + + /** + * List all exposed URLs in the sandbox, keyed by port. + */ + public async listUrls(): Promise> { const resp = await beamClient.request({ method: "GET", url: `/api/v1/gateway/pods/${this.containerId}/urls`, @@ -383,7 +416,13 @@ export class SandboxInstance extends PodInstance { const data = resp.data as PodSandboxListUrlsResponse; if (!data.ok) throw new SandboxProcessError(data.errorMsg || "Failed to list URLs"); - return Object.values(data.urls || {}); + + const urlsByPort: Record = {}; + for (const [port, url] of Object.entries(data.urls || {})) { + urlsByPort[Number(port)] = url; + } + + return urlsByPort; } /** diff --git a/lib/resources/abstraction/stub.ts b/lib/resources/abstraction/stub.ts index 2e2b41f..4ebe76e 100644 --- a/lib/resources/abstraction/stub.ts +++ b/lib/resources/abstraction/stub.ts @@ -52,6 +52,8 @@ export interface StubConfig { inputs?: Schema; outputs?: Schema; tcp: boolean; + blockNetwork: boolean; + allowList?: string[]; } export interface CreateStubConfig extends Partial { @@ -110,6 +112,8 @@ export class StubBuilder { inputs = undefined, outputs = undefined, tcp = false, + blockNetwork = false, + allowList = undefined, }: CreateStubConfig) { this.config = {} as StubConfig; this.config.name = name; @@ -137,6 +141,14 @@ export class StubBuilder { this.config.pricing = pricing; this.config.inputs = inputs; this.config.outputs = outputs; + this.config.blockNetwork = blockNetwork; + this.config.allowList = allowList; + + if (this.config.blockNetwork && this.config.allowList !== undefined) { + throw new Error( + "Cannot specify both 'blockNetwork=true' and 'allowList'. Use 'allowList' with CIDR notation to allow specific ranges, or use 'blockNetwork=true' to block all outbound traffic." + ); + } // Set GPU count if GPU specified but count is 0 if ( @@ -340,6 +352,8 @@ export class StubBuilder { inputs, outputs, tcp: this.config.tcp, + blockNetwork: this.config.blockNetwork, + allowList: this.config.allowList, }; try { diff --git a/lib/types/pod.ts b/lib/types/pod.ts index 411f528..f42fd3f 100644 --- a/lib/types/pod.ts +++ b/lib/types/pod.ts @@ -217,6 +217,18 @@ export interface PodSandboxExposePortResponse { errorMsg: string; } +export interface PodSandboxUpdateNetworkPermissionsRequest { + containerId: string; + stubId: string; + blockNetwork: boolean; + allowList: string[]; +} + +export interface PodSandboxUpdateNetworkPermissionsResponse { + ok: boolean; + errorMsg: string; +} + export interface PodSandboxListUrlsResponse { ok: boolean; urls: Record; diff --git a/lib/types/stub.ts b/lib/types/stub.ts index f241cf3..ad3d09f 100644 --- a/lib/types/stub.ts +++ b/lib/types/stub.ts @@ -72,6 +72,8 @@ export interface GetOrCreateStubRequest { inputs?: Schema; outputs?: Schema; tcp: boolean; + blockNetwork: boolean; + allowList?: string[]; } export interface GetOrCreateStubResponse { diff --git a/tests/sandbox-network.test.ts b/tests/sandbox-network.test.ts new file mode 100644 index 0000000..c758571 --- /dev/null +++ b/tests/sandbox-network.test.ts @@ -0,0 +1,143 @@ +import beamClient from "../lib"; +import { Sandbox, SandboxInstance } from "../lib/resources/abstraction/sandbox"; +import { EStubType } from "../lib/types/stub"; + +describe("Sandbox network parity", () => { + beforeEach(() => { + jest.spyOn(console, "log").mockImplementation(() => undefined); + jest.spyOn(console, "warn").mockImplementation(() => undefined); + jest.spyOn(console, "error").mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test("rejects sandbox configs that set both blockNetwork and allowList", () => { + expect(() => { + new Sandbox({ + name: "networked-sandbox", + blockNetwork: true, + allowList: ["8.8.8.8/32"], + }); + }).toThrow( + "Cannot specify both 'blockNetwork=true' and 'allowList'. Use 'allowList' with CIDR notation to allow specific ranges, or use 'blockNetwork=true' to block all outbound traffic." + ); + }); + + test("includes allowList in stub creation requests", async () => { + const requestMock = jest.spyOn(beamClient, "request").mockResolvedValue({ + data: { + ok: true, + stubId: "stub-123", + }, + }); + + const sandbox = new Sandbox({ + name: "networked-sandbox", + allowList: ["8.8.8.8/32"], + }); + + sandbox.stub.imageAvailable = true; + sandbox.stub.filesSynced = true; + sandbox.stub.objectId = "object-123"; + sandbox.stub.config.image.id = "image-123"; + + await expect( + sandbox.stub.prepareRuntime(undefined, EStubType.Sandbox, true, ["*"]) + ).resolves.toBe(true); + + expect(requestMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: "/api/v1/gateway/stubs", + data: expect.objectContaining({ + block_network: false, + allow_list: ["8.8.8.8/32"], + }), + }) + ); + }); + + test("updates network permissions with the sandbox update endpoint", async () => { + const requestMock = jest.spyOn(beamClient, "request").mockResolvedValue({ + data: { + ok: true, + errorMsg: "", + }, + }); + + const instance = new SandboxInstance( + { + containerId: "sandbox-123", + stubId: "stub-123", + url: "", + ok: true, + errorMsg: "", + }, + new Sandbox({ name: "networked-sandbox" }) + ); + + await expect(instance.updateNetworkPermissions(true)).resolves.toBeUndefined(); + + expect(requestMock).toHaveBeenCalledWith({ + method: "POST", + url: "/api/v1/gateway/pods/sandbox-123/network/update", + data: { + stubId: "stub-123", + blockNetwork: true, + allowList: [], + }, + }); + }); + + test("rejects conflicting network permission updates before making a request", async () => { + const requestMock = jest.spyOn(beamClient, "request"); + + const instance = new SandboxInstance( + { + containerId: "sandbox-123", + stubId: "stub-123", + url: "", + ok: true, + errorMsg: "", + }, + new Sandbox({ name: "networked-sandbox" }) + ); + + await expect( + instance.updateNetworkPermissions(true, ["8.8.8.8/32"]) + ).rejects.toThrow("Cannot specify both blockNetwork=true and allowList"); + + expect(requestMock).not.toHaveBeenCalled(); + }); + + test("returns exposed URLs keyed by port", async () => { + jest.spyOn(beamClient, "request").mockResolvedValue({ + data: { + ok: true, + urls: { + "3000": "https://3000.example.com", + "8080": "https://8080.example.com", + }, + errorMsg: "", + }, + }); + + const instance = new SandboxInstance( + { + containerId: "sandbox-123", + stubId: "stub-123", + url: "", + ok: true, + errorMsg: "", + }, + new Sandbox({ name: "networked-sandbox" }) + ); + + await expect(instance.listUrls()).resolves.toEqual({ + 3000: "https://3000.example.com", + 8080: "https://8080.example.com", + }); + }); +}); From 9ef69eb50c56bf1748cfd285e117de4d6317e925 Mon Sep 17 00:00:00 2001 From: Cooper Date: Mon, 9 Mar 2026 15:10:08 -0700 Subject: [PATCH 2/2] Small improvements --- lib/resources/abstraction/sandbox.ts | 14 +++++++++++--- tests/sandbox-network.test.ts | 27 ++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/lib/resources/abstraction/sandbox.ts b/lib/resources/abstraction/sandbox.ts index d8eff78..ec0c6cf 100644 --- a/lib/resources/abstraction/sandbox.ts +++ b/lib/resources/abstraction/sandbox.ts @@ -379,13 +379,21 @@ export class SandboxInstance extends PodInstance { /** * Dynamically update outbound network permissions for the sandbox. + * + * Parameters: + * - blockNetwork (boolean): If true, block all outbound network access. Defaults to false. + * - allowList (string[]): Optional list of allowed outbound domains/IPs. Cannot be used with blockNetwork=true. + * + * Throws: SandboxConnectionError if the update fails. */ public async updateNetworkPermissions( blockNetwork: boolean = false, allowList?: string[] ): Promise { - if (blockNetwork && allowList && allowList.length > 0) { - throw new Error("Cannot specify both blockNetwork=true and allowList"); + if (blockNetwork && allowList !== undefined) { + throw new Error( + "Cannot specify both 'blockNetwork=true' and 'allowList'. Use 'allowList' with CIDR notation to allow specific ranges, or use 'blockNetwork=true' to block all outbound traffic." + ); } const resp = await beamClient.request({ @@ -399,7 +407,7 @@ export class SandboxInstance extends PodInstance { }); const data = resp.data as PodSandboxUpdateNetworkPermissionsResponse; if (!data.ok) { - throw new SandboxProcessError( + throw new SandboxConnectionError( data.errorMsg || "Failed to update network permissions" ); } diff --git a/tests/sandbox-network.test.ts b/tests/sandbox-network.test.ts index c758571..dd16d28 100644 --- a/tests/sandbox-network.test.ts +++ b/tests/sandbox-network.test.ts @@ -107,7 +107,32 @@ describe("Sandbox network parity", () => { await expect( instance.updateNetworkPermissions(true, ["8.8.8.8/32"]) - ).rejects.toThrow("Cannot specify both blockNetwork=true and allowList"); + ).rejects.toThrow( + "Cannot specify both 'blockNetwork=true' and 'allowList'. Use 'allowList' with CIDR notation to allow specific ranges, or use 'blockNetwork=true' to block all outbound traffic." + ); + + expect(requestMock).not.toHaveBeenCalled(); + }); + + test("rejects blockNetwork=true with empty allowList", async () => { + const requestMock = jest.spyOn(beamClient, "request"); + + const instance = new SandboxInstance( + { + containerId: "sandbox-123", + stubId: "stub-123", + url: "", + ok: true, + errorMsg: "", + }, + new Sandbox({ name: "networked-sandbox" }) + ); + + await expect( + instance.updateNetworkPermissions(true, []) + ).rejects.toThrow( + "Cannot specify both 'blockNetwork=true' and 'allowList'. Use 'allowList' with CIDR notation to allow specific ranges, or use 'blockNetwork=true' to block all outbound traffic." + ); expect(requestMock).not.toHaveBeenCalled(); });