From 85f8a4513e67d0685c7e45cbb28629c955572022 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:39:16 +0200 Subject: [PATCH 01/23] feat(sdk): support structured network rules with per-host transforms Extends SandboxNetworkConfig.allowOut/denyOut to accept objects of the form { host, transform: [{ headers }] } alongside plain string entries. Updates OpenAPI spec, regenerates JS + Python clients, surfaces new TS types (SandboxNetworkRule, SandboxNetworkRuleTransform, SandboxNetworkEntry), and adds a contract test that curls httpbin.org/headers and asserts an injected Authorization-style header is reflected back. Co-Authored-By: Claude Opus 4.7 --- packages/js-sdk/src/api/schema.gen.ts | 22 ++++- packages/js-sdk/src/index.ts | 3 + packages/js-sdk/src/sandbox/sandboxApi.ts | 41 ++++++++- packages/js-sdk/tests/sandbox/network.test.ts | 48 ++++++++++ .../e2b/api/client/models/__init__.py | 6 ++ .../client/models/sandbox_network_config.py | 82 ++++++++++++++--- .../api/client/models/sandbox_network_rule.py | 88 +++++++++++++++++++ .../models/sandbox_network_rule_transform.py | 78 ++++++++++++++++ .../sandbox_network_rule_transform_headers.py | 44 ++++++++++ spec/openapi.yml | 37 +++++++- 10 files changed, 425 insertions(+), 24 deletions(-) create mode 100644 packages/python-sdk/e2b/api/client/models/sandbox_network_rule.py create mode 100644 packages/python-sdk/e2b/api/client/models/sandbox_network_rule_transform.py create mode 100644 packages/python-sdk/e2b/api/client/models/sandbox_network_rule_transform_headers.py diff --git a/packages/js-sdk/src/api/schema.gen.ts b/packages/js-sdk/src/api/schema.gen.ts index d3c42e3ee2..572073fbbc 100644 --- a/packages/js-sdk/src/api/schema.gen.ts +++ b/packages/js-sdk/src/api/schema.gen.ts @@ -2279,18 +2279,32 @@ export interface components { timestampUnix: number; }; SandboxNetworkConfig: { - /** @description List of allowed CIDR blocks or IP addresses for egress traffic. Allowed addresses always take precedence over blocked addresses. */ - allowOut?: string[]; + /** @description List of allowed egress entries. Each entry is either a CIDR block/IP/host string, or a structured rule with optional per-host transforms. Allowed entries always take precedence over denied ones. */ + allowOut?: (string | components["schemas"]["SandboxNetworkRule"])[]; /** * @description Specify if the sandbox URLs should be accessible only with authentication. * @default true */ allowPublicTraffic?: boolean; - /** @description List of denied CIDR blocks or IP addresses for egress traffic */ - denyOut?: string[]; + /** @description List of denied egress entries. Each entry is either a CIDR block/IP/host string, or a structured rule. */ + denyOut?: (string | components["schemas"]["SandboxNetworkRule"])[]; /** @description Specify host mask which will be used for all sandbox requests */ maskRequestHost?: string; }; + /** @description Structured egress rule matching a host, optionally transforming the request before it leaves the sandbox. */ + SandboxNetworkRule: { + /** @description Host, CIDR block, or IP address the rule applies to. */ + host: string; + /** @description Ordered list of transforms to apply to requests matching this rule. */ + transform?: components["schemas"]["SandboxNetworkRuleTransform"][]; + }; + /** @description Transform applied to egress requests matching a network rule. */ + SandboxNetworkRuleTransform: { + /** @description Headers to inject into the outbound request. Values override any headers already present. */ + headers?: { + [key: string]: string; + }; + }; /** * @description Action taken when the sandbox times out. * @enum {string} diff --git a/packages/js-sdk/src/index.ts b/packages/js-sdk/src/index.ts index e67092a01c..6ceb31ae78 100644 --- a/packages/js-sdk/src/index.ts +++ b/packages/js-sdk/src/index.ts @@ -58,6 +58,9 @@ export type { SandboxListOpts, SandboxPaginator, SandboxNetworkOpts, + SandboxNetworkRule, + SandboxNetworkRuleTransform, + SandboxNetworkEntry, SandboxLifecycle, SandboxInfoLifecycle, SnapshotInfo, diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 6fc03addb7..d8fdcffc51 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -33,23 +33,56 @@ export type GitHubMcpServer = { } } +/** + * Transform applied to outbound requests matching a {@link SandboxNetworkRule}. + */ +export type SandboxNetworkRuleTransform = { + /** + * Headers to inject into the outbound request. Values override any headers + * already present on the request. + */ + headers?: Record +} + +/** + * Structured egress rule for {@link SandboxNetworkOpts.allowOut} or + * {@link SandboxNetworkOpts.denyOut}. + */ +export type SandboxNetworkRule = { + /** Host, CIDR block, or IP address the rule applies to. */ + host: string + /** Ordered list of transforms to apply to requests matching this rule. */ + transform?: SandboxNetworkRuleTransform[] +} + +export type SandboxNetworkEntry = string | SandboxNetworkRule + export type SandboxNetworkOpts = { /** * Allow outbound traffic from the sandbox to the specified addresses. * If `allowOut` is not specified, all outbound traffic is allowed. * + * Each entry is either a string (CIDR block, IP address, or host) or a + * structured {@link SandboxNetworkRule} that can additionally describe + * per-host request transforms (for example, header injection). + * * Examples: - * - To allow traffic to a specific addresses: `["1.1.1.1", "8.8.8.0/24"]` + * - Allow traffic to specific addresses: `["1.1.1.1", "8.8.8.0/24"]` + * - Allow a host and inject a header on matching requests: + * `[{ host: "api.openai.com", transform: [{ headers: { Authorization: "Bearer ..." } }] }]` */ - allowOut?: string[] + allowOut?: SandboxNetworkEntry[] /** * Deny outbound traffic from the sandbox to the specified addresses. * + * Each entry is either a string (CIDR block, IP address, or host) or a + * structured {@link SandboxNetworkRule}. + * * Examples: - * - To deny traffic to a specific addresses: `["1.1.1.1", "8.8.8.0/24"]` + * - Deny traffic to specific addresses: `["1.1.1.1", "8.8.8.0/24"]` */ - denyOut?: string[] + denyOut?: SandboxNetworkEntry[] /** * Specify if the sandbox URLs should be accessible only with authentication. diff --git a/packages/js-sdk/tests/sandbox/network.test.ts b/packages/js-sdk/tests/sandbox/network.test.ts index b6d2ebe23c..8a61a7a8ef 100644 --- a/packages/js-sdk/tests/sandbox/network.test.ts +++ b/packages/js-sdk/tests/sandbox/network.test.ts @@ -193,6 +193,54 @@ describe('allowPublicTraffic=true', () => { ) }) +describe('allowOut transform injects headers', () => { + const injectedHeader = 'X-E2B-Test-Token' + const injectedValue = 'e2b-transform-value-123' + + sandboxTest.scoped({ + sandboxOpts: { + network: { + denyOut: [ALL_TRAFFIC], + allowOut: [ + { + host: 'httpbin.org', + }, + { + host: 'httpbin.org', + transform: [ + { + headers: { + [injectedHeader]: injectedValue, + }, + }, + ], + }, + ], + }, + }, + }) + + sandboxTest.skipIf(isDebug)( + 'injected header is reflected by httpbin.org/headers', + async ({ sandbox }) => { + const result = await sandbox.commands.run( + 'curl -sS --max-time 10 https://httpbin.org/headers' + ) + assert.equal(result.exitCode, 0) + + const parsed = JSON.parse(result.stdout) as { + headers: Record + } + const reflected = parsed.headers[injectedHeader] + assert.equal( + reflected, + injectedValue, + `expected httpbin to reflect ${injectedHeader}=${injectedValue}, got headers: ${JSON.stringify(parsed.headers)}` + ) + } + ) +}) + describe('maskRequestHost option', () => { sandboxTest.scoped({ sandboxOpts: { diff --git a/packages/python-sdk/e2b/api/client/models/__init__.py b/packages/python-sdk/e2b/api/client/models/__init__.py index fb263334ff..f9c527d057 100644 --- a/packages/python-sdk/e2b/api/client/models/__init__.py +++ b/packages/python-sdk/e2b/api/client/models/__init__.py @@ -54,6 +54,9 @@ from .sandbox_logs_v2_response import SandboxLogsV2Response from .sandbox_metric import SandboxMetric from .sandbox_network_config import SandboxNetworkConfig +from .sandbox_network_rule import SandboxNetworkRule +from .sandbox_network_rule_transform import SandboxNetworkRuleTransform +from .sandbox_network_rule_transform_headers import SandboxNetworkRuleTransformHeaders from .sandbox_on_timeout import SandboxOnTimeout from .sandbox_state import SandboxState from .sandbox_volume_mount import SandboxVolumeMount @@ -138,6 +141,9 @@ "SandboxLogsV2Response", "SandboxMetric", "SandboxNetworkConfig", + "SandboxNetworkRule", + "SandboxNetworkRuleTransform", + "SandboxNetworkRuleTransformHeaders", "SandboxOnTimeout", "SandboxState", "SandboxVolumeMount", diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py b/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py index 08284c792f..7e104c65a6 100644 --- a/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py +++ b/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py @@ -1,11 +1,15 @@ from collections.abc import Mapping -from typing import Any, TypeVar, Union, cast +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast from attrs import define as _attrs_define from attrs import field as _attrs_field from ..types import UNSET, Unset +if TYPE_CHECKING: + from ..models.sandbox_network_rule import SandboxNetworkRule + + T = TypeVar("T", bound="SandboxNetworkConfig") @@ -13,30 +17,48 @@ class SandboxNetworkConfig: """ Attributes: - allow_out (Union[Unset, list[str]]): List of allowed CIDR blocks or IP addresses for egress traffic. Allowed - addresses always take precedence over blocked addresses. + allow_out (Union[Unset, list[Union['SandboxNetworkRule', str]]]): List of allowed egress entries. Each entry is + either a CIDR block/IP/host string, or a structured rule with optional per-host transforms. Allowed entries + always take precedence over denied ones. allow_public_traffic (Union[Unset, bool]): Specify if the sandbox URLs should be accessible only with authentication. Default: True. - deny_out (Union[Unset, list[str]]): List of denied CIDR blocks or IP addresses for egress traffic + deny_out (Union[Unset, list[Union['SandboxNetworkRule', str]]]): List of denied egress entries. Each entry is + either a CIDR block/IP/host string, or a structured rule. mask_request_host (Union[Unset, str]): Specify host mask which will be used for all sandbox requests """ - allow_out: Union[Unset, list[str]] = UNSET + allow_out: Union[Unset, list[Union["SandboxNetworkRule", str]]] = UNSET allow_public_traffic: Union[Unset, bool] = True - deny_out: Union[Unset, list[str]] = UNSET + deny_out: Union[Unset, list[Union["SandboxNetworkRule", str]]] = UNSET mask_request_host: Union[Unset, str] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: - allow_out: Union[Unset, list[str]] = UNSET + from ..models.sandbox_network_rule import SandboxNetworkRule + + allow_out: Union[Unset, list[Union[dict[str, Any], str]]] = UNSET if not isinstance(self.allow_out, Unset): - allow_out = self.allow_out + allow_out = [] + for allow_out_item_data in self.allow_out: + allow_out_item: Union[dict[str, Any], str] + if isinstance(allow_out_item_data, SandboxNetworkRule): + allow_out_item = allow_out_item_data.to_dict() + else: + allow_out_item = allow_out_item_data + allow_out.append(allow_out_item) allow_public_traffic = self.allow_public_traffic - deny_out: Union[Unset, list[str]] = UNSET + deny_out: Union[Unset, list[Union[dict[str, Any], str]]] = UNSET if not isinstance(self.deny_out, Unset): - deny_out = self.deny_out + deny_out = [] + for deny_out_item_data in self.deny_out: + deny_out_item: Union[dict[str, Any], str] + if isinstance(deny_out_item_data, SandboxNetworkRule): + deny_out_item = deny_out_item_data.to_dict() + else: + deny_out_item = deny_out_item_data + deny_out.append(deny_out_item) mask_request_host = self.mask_request_host @@ -56,12 +78,48 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.sandbox_network_rule import SandboxNetworkRule + d = dict(src_dict) - allow_out = cast(list[str], d.pop("allowOut", UNSET)) + allow_out = [] + _allow_out = d.pop("allowOut", UNSET) + for allow_out_item_data in _allow_out or []: + + def _parse_allow_out_item(data: object) -> Union["SandboxNetworkRule", str]: + try: + if not isinstance(data, dict): + raise TypeError() + allow_out_item_type_1 = SandboxNetworkRule.from_dict(data) + + return allow_out_item_type_1 + except: # noqa: E722 + pass + return cast(Union["SandboxNetworkRule", str], data) + + allow_out_item = _parse_allow_out_item(allow_out_item_data) + + allow_out.append(allow_out_item) allow_public_traffic = d.pop("allowPublicTraffic", UNSET) - deny_out = cast(list[str], d.pop("denyOut", UNSET)) + deny_out = [] + _deny_out = d.pop("denyOut", UNSET) + for deny_out_item_data in _deny_out or []: + + def _parse_deny_out_item(data: object) -> Union["SandboxNetworkRule", str]: + try: + if not isinstance(data, dict): + raise TypeError() + deny_out_item_type_1 = SandboxNetworkRule.from_dict(data) + + return deny_out_item_type_1 + except: # noqa: E722 + pass + return cast(Union["SandboxNetworkRule", str], data) + + deny_out_item = _parse_deny_out_item(deny_out_item_data) + + deny_out.append(deny_out_item) mask_request_host = d.pop("maskRequestHost", UNSET) diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_network_rule.py b/packages/python-sdk/e2b/api/client/models/sandbox_network_rule.py new file mode 100644 index 0000000000..aef0bfff15 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_network_rule.py @@ -0,0 +1,88 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.sandbox_network_rule_transform import SandboxNetworkRuleTransform + + +T = TypeVar("T", bound="SandboxNetworkRule") + + +@_attrs_define +class SandboxNetworkRule: + """Structured egress rule matching a host, optionally transforming the request before it leaves the sandbox. + + Attributes: + host (str): Host, CIDR block, or IP address the rule applies to. + transform (Union[Unset, list['SandboxNetworkRuleTransform']]): Ordered list of transforms to apply to requests + matching this rule. + """ + + host: str + transform: Union[Unset, list["SandboxNetworkRuleTransform"]] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + host = self.host + + transform: Union[Unset, list[dict[str, Any]]] = UNSET + if not isinstance(self.transform, Unset): + transform = [] + for transform_item_data in self.transform: + transform_item = transform_item_data.to_dict() + transform.append(transform_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "host": host, + } + ) + if transform is not UNSET: + field_dict["transform"] = transform + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.sandbox_network_rule_transform import SandboxNetworkRuleTransform + + d = dict(src_dict) + host = d.pop("host") + + transform = [] + _transform = d.pop("transform", UNSET) + for transform_item_data in _transform or []: + transform_item = SandboxNetworkRuleTransform.from_dict(transform_item_data) + + transform.append(transform_item) + + sandbox_network_rule = cls( + host=host, + transform=transform, + ) + + sandbox_network_rule.additional_properties = d + return sandbox_network_rule + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_network_rule_transform.py b/packages/python-sdk/e2b/api/client/models/sandbox_network_rule_transform.py new file mode 100644 index 0000000000..9fad5cf001 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_network_rule_transform.py @@ -0,0 +1,78 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.sandbox_network_rule_transform_headers import ( + SandboxNetworkRuleTransformHeaders, + ) + + +T = TypeVar("T", bound="SandboxNetworkRuleTransform") + + +@_attrs_define +class SandboxNetworkRuleTransform: + """Transform applied to egress requests matching a network rule. + + Attributes: + headers (Union[Unset, SandboxNetworkRuleTransformHeaders]): Headers to inject into the outbound request. Values + override any headers already present. + """ + + headers: Union[Unset, "SandboxNetworkRuleTransformHeaders"] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + headers: Union[Unset, dict[str, Any]] = UNSET + if not isinstance(self.headers, Unset): + headers = self.headers.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if headers is not UNSET: + field_dict["headers"] = headers + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.sandbox_network_rule_transform_headers import ( + SandboxNetworkRuleTransformHeaders, + ) + + d = dict(src_dict) + _headers = d.pop("headers", UNSET) + headers: Union[Unset, SandboxNetworkRuleTransformHeaders] + if isinstance(_headers, Unset): + headers = UNSET + else: + headers = SandboxNetworkRuleTransformHeaders.from_dict(_headers) + + sandbox_network_rule_transform = cls( + headers=headers, + ) + + sandbox_network_rule_transform.additional_properties = d + return sandbox_network_rule_transform + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_network_rule_transform_headers.py b/packages/python-sdk/e2b/api/client/models/sandbox_network_rule_transform_headers.py new file mode 100644 index 0000000000..6c79edd9c8 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_network_rule_transform_headers.py @@ -0,0 +1,44 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="SandboxNetworkRuleTransformHeaders") + + +@_attrs_define +class SandboxNetworkRuleTransformHeaders: + """Headers to inject into the outbound request. Values override any headers already present.""" + + additional_properties: dict[str, str] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + sandbox_network_rule_transform_headers = cls() + + sandbox_network_rule_transform_headers.additional_properties = d + return sandbox_network_rule_transform_headers + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> str: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: str) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/spec/openapi.yml b/spec/openapi.yml index 87084f64b9..20e4c0a1f3 100644 --- a/spec/openapi.yml +++ b/spec/openapi.yml @@ -262,6 +262,31 @@ components: additionalProperties: {} nullable: true + SandboxNetworkRuleTransform: + type: object + description: Transform applied to egress requests matching a network rule. + properties: + headers: + type: object + description: Headers to inject into the outbound request. Values override any headers already present. + additionalProperties: + type: string + + SandboxNetworkRule: + type: object + description: Structured egress rule matching a host, optionally transforming the request before it leaves the sandbox. + required: + - host + properties: + host: + type: string + description: Host, CIDR block, or IP address the rule applies to. + transform: + type: array + description: Ordered list of transforms to apply to requests matching this rule. + items: + $ref: '#/components/schemas/SandboxNetworkRuleTransform' + SandboxNetworkConfig: type: object properties: @@ -271,14 +296,18 @@ components: description: Specify if the sandbox URLs should be accessible only with authentication. allowOut: type: array - description: List of allowed CIDR blocks or IP addresses for egress traffic. Allowed addresses always take precedence over blocked addresses. + description: List of allowed egress entries. Each entry is either a CIDR block/IP/host string, or a structured rule with optional per-host transforms. Allowed entries always take precedence over denied ones. items: - type: string + oneOf: + - type: string + - $ref: '#/components/schemas/SandboxNetworkRule' denyOut: type: array - description: List of denied CIDR blocks or IP addresses for egress traffic + description: List of denied egress entries. Each entry is either a CIDR block/IP/host string, or a structured rule. items: - type: string + oneOf: + - type: string + - $ref: '#/components/schemas/SandboxNetworkRule' maskRequestHost: type: string description: Specify host mask which will be used for all sandbox requests From 5229a6fc34b7989607dce46a409174b44ec1b0a0 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:49:08 +0200 Subject: [PATCH 02/23] feat(python-sdk): support structured network rules and revert denyOut - Adds SandboxNetworkRule and SandboxNetworkRuleTransform TypedDicts to the Python SDK, widens SandboxNetworkOpts.allow_out to accept them, and exposes them from the top-level e2b package. - Mirrors the TS contract: deny_out stays as List[str], only allow_out gains the object form with optional per-host transforms. - Reverts denyOut in the OpenAPI spec to items: string only (allowOut keeps the oneOf form with SandboxNetworkRule) and regenerates the JS + Python clients to match. - Adds httpbin.org/headers transform tests for both async and sync Python sandbox tests, parallel to the TS case. Co-Authored-By: Claude Opus 4.7 --- packages/js-sdk/src/api/schema.gen.ts | 4 +- packages/js-sdk/src/sandbox/sandboxApi.ts | 10 +--- packages/python-sdk/e2b/__init__.py | 6 ++ .../client/models/sandbox_network_config.py | 35 ++---------- .../python-sdk/e2b/sandbox/sandbox_api.py | 55 ++++++++++++++++++- .../tests/async/sandbox_async/test_network.py | 45 ++++++++++++++- .../tests/sync/sandbox_sync/test_network.py | 43 ++++++++++++++- spec/openapi.yml | 6 +- 8 files changed, 156 insertions(+), 48 deletions(-) diff --git a/packages/js-sdk/src/api/schema.gen.ts b/packages/js-sdk/src/api/schema.gen.ts index 572073fbbc..0e61ee04aa 100644 --- a/packages/js-sdk/src/api/schema.gen.ts +++ b/packages/js-sdk/src/api/schema.gen.ts @@ -2286,8 +2286,8 @@ export interface components { * @default true */ allowPublicTraffic?: boolean; - /** @description List of denied egress entries. Each entry is either a CIDR block/IP/host string, or a structured rule. */ - denyOut?: (string | components["schemas"]["SandboxNetworkRule"])[]; + /** @description List of denied CIDR blocks or IP addresses for egress traffic */ + denyOut?: string[]; /** @description Specify host mask which will be used for all sandbox requests */ maskRequestHost?: string; }; diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index d8fdcffc51..0bb07c0ba1 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -45,8 +45,7 @@ export type SandboxNetworkRuleTransform = { } /** - * Structured egress rule for {@link SandboxNetworkOpts.allowOut} or - * {@link SandboxNetworkOpts.denyOut}. + * Structured egress rule for {@link SandboxNetworkOpts.allowOut}. */ export type SandboxNetworkRule = { /** Host, CIDR block, or IP address the rule applies to. */ @@ -76,13 +75,10 @@ export type SandboxNetworkOpts = { /** * Deny outbound traffic from the sandbox to the specified addresses. * - * Each entry is either a string (CIDR block, IP address, or host) or a - * structured {@link SandboxNetworkRule}. - * * Examples: - * - Deny traffic to specific addresses: `["1.1.1.1", "8.8.8.0/24"]` + * - To deny traffic to a specific addresses: `["1.1.1.1", "8.8.8.0/24"]` */ - denyOut?: SandboxNetworkEntry[] + denyOut?: string[] /** * Specify if the sandbox URLs should be accessible only with authentication. diff --git a/packages/python-sdk/e2b/__init__.py b/packages/python-sdk/e2b/__init__.py index e43f621e41..1185839245 100644 --- a/packages/python-sdk/e2b/__init__.py +++ b/packages/python-sdk/e2b/__init__.py @@ -79,7 +79,10 @@ SandboxInfoLifecycle, SandboxMetrics, SandboxLifecycle, + SandboxNetworkEntry, SandboxNetworkOpts, + SandboxNetworkRule, + SandboxNetworkRuleTransform, SandboxQuery, SandboxState, SnapshotInfo, @@ -183,6 +186,9 @@ "FileType", # Network "SandboxNetworkOpts", + "SandboxNetworkRule", + "SandboxNetworkRuleTransform", + "SandboxNetworkEntry", "SandboxLifecycle", "ALL_TRAFFIC", # Snapshot diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py b/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py index 7e104c65a6..971f7f049d 100644 --- a/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py +++ b/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py @@ -22,14 +22,13 @@ class SandboxNetworkConfig: always take precedence over denied ones. allow_public_traffic (Union[Unset, bool]): Specify if the sandbox URLs should be accessible only with authentication. Default: True. - deny_out (Union[Unset, list[Union['SandboxNetworkRule', str]]]): List of denied egress entries. Each entry is - either a CIDR block/IP/host string, or a structured rule. + deny_out (Union[Unset, list[str]]): List of denied CIDR blocks or IP addresses for egress traffic mask_request_host (Union[Unset, str]): Specify host mask which will be used for all sandbox requests """ allow_out: Union[Unset, list[Union["SandboxNetworkRule", str]]] = UNSET allow_public_traffic: Union[Unset, bool] = True - deny_out: Union[Unset, list[Union["SandboxNetworkRule", str]]] = UNSET + deny_out: Union[Unset, list[str]] = UNSET mask_request_host: Union[Unset, str] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) @@ -49,16 +48,9 @@ def to_dict(self) -> dict[str, Any]: allow_public_traffic = self.allow_public_traffic - deny_out: Union[Unset, list[Union[dict[str, Any], str]]] = UNSET + deny_out: Union[Unset, list[str]] = UNSET if not isinstance(self.deny_out, Unset): - deny_out = [] - for deny_out_item_data in self.deny_out: - deny_out_item: Union[dict[str, Any], str] - if isinstance(deny_out_item_data, SandboxNetworkRule): - deny_out_item = deny_out_item_data.to_dict() - else: - deny_out_item = deny_out_item_data - deny_out.append(deny_out_item) + deny_out = self.deny_out mask_request_host = self.mask_request_host @@ -102,24 +94,7 @@ def _parse_allow_out_item(data: object) -> Union["SandboxNetworkRule", str]: allow_public_traffic = d.pop("allowPublicTraffic", UNSET) - deny_out = [] - _deny_out = d.pop("denyOut", UNSET) - for deny_out_item_data in _deny_out or []: - - def _parse_deny_out_item(data: object) -> Union["SandboxNetworkRule", str]: - try: - if not isinstance(data, dict): - raise TypeError() - deny_out_item_type_1 = SandboxNetworkRule.from_dict(data) - - return deny_out_item_type_1 - except: # noqa: E722 - pass - return cast(Union["SandboxNetworkRule", str], data) - - deny_out_item = _parse_deny_out_item(deny_out_item_data) - - deny_out.append(deny_out_item) + deny_out = cast(list[str], d.pop("denyOut", UNSET)) mask_request_host = d.pop("maskRequestHost", UNSET) diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index 96e4d46271..75cd65d462 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -10,6 +10,7 @@ SandboxDetail, SandboxLifecycle as ClientSandboxLifecycle, SandboxNetworkConfig as ClientSandboxNetworkConfig, + SandboxNetworkRule as ClientSandboxNetworkRule, SandboxState, ) from e2b.api.client.types import Unset @@ -45,18 +46,51 @@ class GitHubMcpServerConfig(TypedDict): McpServer = Union[BaseMcpServer, GitHubMcpServer] +class SandboxNetworkRuleTransform(TypedDict): + """ + Transform applied to outbound requests matching a `SandboxNetworkRule`. + """ + + headers: NotRequired[Dict[str, str]] + """ + Headers to inject into the outbound request. Values override any headers + already present on the request. + """ + + +class SandboxNetworkRule(TypedDict): + """ + Structured egress rule for `SandboxNetworkOpts.allow_out`. + """ + + host: str + """Host, CIDR block, or IP address the rule applies to.""" + + transform: NotRequired[List[SandboxNetworkRuleTransform]] + """Ordered list of transforms to apply to requests matching this rule.""" + + +SandboxNetworkEntry = Union[str, SandboxNetworkRule] + + class SandboxNetworkOpts(TypedDict): """ Sandbox network configuration options. """ - allow_out: NotRequired[List[str]] + allow_out: NotRequired[List[SandboxNetworkEntry]] """ Allow outbound traffic from the sandbox to the specified addresses. If `allow_out` is not specified, all outbound traffic is allowed. + Each entry is either a string (CIDR block, IP address, or host) or a + structured `SandboxNetworkRule` that can additionally describe per-host + request transforms (for example, header injection). + Examples: - - To allow traffic to specific addresses: `["1.1.1.1", "8.8.8.0/24"]` + - Allow traffic to specific addresses: `["1.1.1.1", "8.8.8.0/24"]` + - Allow a host and inject a header on matching requests: + `[{"host": "api.openai.com", "transform": [{"headers": {"Authorization": "Bearer ..."}}]}]` """ deny_out: NotRequired[List[str]] @@ -133,7 +167,22 @@ def from_client_network_config( result: SandboxNetworkOpts = {} if not isinstance(network.allow_out, Unset): - result["allow_out"] = list(network.allow_out) + result["allow_out"] = [ + cast( + SandboxNetworkRule, + { + "host": item.host, + **( + {"transform": [t.to_dict() for t in (item.transform or [])]} + if not isinstance(item.transform, Unset) + else {} + ), + }, + ) + if isinstance(item, ClientSandboxNetworkRule) + else item + for item in network.allow_out + ] if not isinstance(network.deny_out, Unset): result["deny_out"] = list(network.deny_out) if not isinstance(network.allow_public_traffic, Unset): diff --git a/packages/python-sdk/tests/async/sandbox_async/test_network.py b/packages/python-sdk/tests/async/sandbox_async/test_network.py index 8c008371cf..e1ed9a93eb 100644 --- a/packages/python-sdk/tests/async/sandbox_async/test_network.py +++ b/packages/python-sdk/tests/async/sandbox_async/test_network.py @@ -1,6 +1,13 @@ +import json + import pytest -from e2b import ALL_TRAFFIC, SandboxNetworkOpts +from e2b import ( + ALL_TRAFFIC, + SandboxNetworkOpts, + SandboxNetworkRule, + SandboxNetworkRuleTransform, +) from e2b.sandbox.commands.command_handle import CommandExitException @@ -159,6 +166,42 @@ async def test_allow_public_traffic_true(async_sandbox_factory): assert response.status_code == 200 +@pytest.mark.skip_debug() +async def test_allow_out_transform_injects_headers(async_sandbox_factory): + """Test that an allow_out rule with a transform injects headers into outbound requests.""" + injected_header = "X-E2B-Test-Token" + injected_value = "e2b-transform-value-123" + + async_sandbox = await async_sandbox_factory( + network=SandboxNetworkOpts( + deny_out=[ALL_TRAFFIC], + allow_out=[ + SandboxNetworkRule(host="httpbin.org"), + SandboxNetworkRule( + host="httpbin.org", + transform=[ + SandboxNetworkRuleTransform( + headers={injected_header: injected_value} + ) + ], + ), + ], + ), + ) + + result = await async_sandbox.commands.run( + "curl -sS --max-time 10 https://httpbin.org/headers" + ) + assert result.exit_code == 0 + + parsed = json.loads(result.stdout) + reflected = parsed["headers"].get(injected_header) + assert reflected == injected_value, ( + f"expected httpbin to reflect {injected_header}={injected_value}, " + f"got headers: {parsed['headers']}" + ) + + @pytest.mark.skip_debug() async def test_mask_request_host(async_sandbox_factory): """Test that mask_request_host modifies the Host header correctly.""" diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_network.py b/packages/python-sdk/tests/sync/sandbox_sync/test_network.py index 0cbe32d972..a055c7c7a3 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_network.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_network.py @@ -1,6 +1,13 @@ +import json + import pytest -from e2b import ALL_TRAFFIC, SandboxNetworkOpts +from e2b import ( + ALL_TRAFFIC, + SandboxNetworkOpts, + SandboxNetworkRule, + SandboxNetworkRuleTransform, +) from e2b.sandbox.commands.command_handle import CommandExitException @@ -157,6 +164,40 @@ def test_allow_public_traffic_true(sandbox_factory): assert response.status_code == 200 +@pytest.mark.skip_debug() +def test_allow_out_transform_injects_headers(sandbox_factory): + """Test that an allow_out rule with a transform injects headers into outbound requests.""" + injected_header = "X-E2B-Test-Token" + injected_value = "e2b-transform-value-123" + + sandbox = sandbox_factory( + network=SandboxNetworkOpts( + deny_out=[ALL_TRAFFIC], + allow_out=[ + SandboxNetworkRule(host="httpbin.org"), + SandboxNetworkRule( + host="httpbin.org", + transform=[ + SandboxNetworkRuleTransform( + headers={injected_header: injected_value} + ) + ], + ), + ], + ), + ) + + result = sandbox.commands.run("curl -sS --max-time 10 https://httpbin.org/headers") + assert result.exit_code == 0 + + parsed = json.loads(result.stdout) + reflected = parsed["headers"].get(injected_header) + assert reflected == injected_value, ( + f"expected httpbin to reflect {injected_header}={injected_value}, " + f"got headers: {parsed['headers']}" + ) + + @pytest.mark.skip_debug() def test_mask_request_host(sandbox_factory): """Test that mask_request_host modifies the Host header correctly.""" diff --git a/spec/openapi.yml b/spec/openapi.yml index 20e4c0a1f3..8508fb2c8e 100644 --- a/spec/openapi.yml +++ b/spec/openapi.yml @@ -303,11 +303,9 @@ components: - $ref: '#/components/schemas/SandboxNetworkRule' denyOut: type: array - description: List of denied egress entries. Each entry is either a CIDR block/IP/host string, or a structured rule. + description: List of denied CIDR blocks or IP addresses for egress traffic items: - oneOf: - - type: string - - $ref: '#/components/schemas/SandboxNetworkRule' + type: string maskRequestHost: type: string description: Specify host mask which will be used for all sandbox requests From 6905b09fae092eb2ecf10ea3176ff503f3a0083a Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:43:05 +0200 Subject: [PATCH 03/23] test(network): trim transform tests to a single allowOut entry - Drops the redundant string-form duplicate of httpbin.org and the denyOut/deny_out scoping; the test now exercises just the structured rule with header transform. - Switches the Python tests to plain-dict literals (typed via a local SandboxNetworkOpts annotation) instead of TypedDict constructors. Co-Authored-By: Claude Opus 4.7 --- packages/js-sdk/tests/sandbox/network.test.ts | 4 --- .../tests/async/sandbox_async/test_network.py | 32 ++++++------------- .../tests/sync/sandbox_sync/test_network.py | 32 ++++++------------- 3 files changed, 20 insertions(+), 48 deletions(-) diff --git a/packages/js-sdk/tests/sandbox/network.test.ts b/packages/js-sdk/tests/sandbox/network.test.ts index 8a61a7a8ef..bd32a28f08 100644 --- a/packages/js-sdk/tests/sandbox/network.test.ts +++ b/packages/js-sdk/tests/sandbox/network.test.ts @@ -200,11 +200,7 @@ describe('allowOut transform injects headers', () => { sandboxTest.scoped({ sandboxOpts: { network: { - denyOut: [ALL_TRAFFIC], allowOut: [ - { - host: 'httpbin.org', - }, { host: 'httpbin.org', transform: [ diff --git a/packages/python-sdk/tests/async/sandbox_async/test_network.py b/packages/python-sdk/tests/async/sandbox_async/test_network.py index e1ed9a93eb..42125d5d54 100644 --- a/packages/python-sdk/tests/async/sandbox_async/test_network.py +++ b/packages/python-sdk/tests/async/sandbox_async/test_network.py @@ -2,12 +2,7 @@ import pytest -from e2b import ( - ALL_TRAFFIC, - SandboxNetworkOpts, - SandboxNetworkRule, - SandboxNetworkRuleTransform, -) +from e2b import ALL_TRAFFIC, SandboxNetworkOpts from e2b.sandbox.commands.command_handle import CommandExitException @@ -172,22 +167,15 @@ async def test_allow_out_transform_injects_headers(async_sandbox_factory): injected_header = "X-E2B-Test-Token" injected_value = "e2b-transform-value-123" - async_sandbox = await async_sandbox_factory( - network=SandboxNetworkOpts( - deny_out=[ALL_TRAFFIC], - allow_out=[ - SandboxNetworkRule(host="httpbin.org"), - SandboxNetworkRule( - host="httpbin.org", - transform=[ - SandboxNetworkRuleTransform( - headers={injected_header: injected_value} - ) - ], - ), - ], - ), - ) + network: SandboxNetworkOpts = { + "allow_out": [ + { + "host": "httpbin.org", + "transform": [{"headers": {injected_header: injected_value}}], + }, + ], + } + async_sandbox = await async_sandbox_factory(network=network) result = await async_sandbox.commands.run( "curl -sS --max-time 10 https://httpbin.org/headers" diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_network.py b/packages/python-sdk/tests/sync/sandbox_sync/test_network.py index a055c7c7a3..a8252225de 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_network.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_network.py @@ -2,12 +2,7 @@ import pytest -from e2b import ( - ALL_TRAFFIC, - SandboxNetworkOpts, - SandboxNetworkRule, - SandboxNetworkRuleTransform, -) +from e2b import ALL_TRAFFIC, SandboxNetworkOpts from e2b.sandbox.commands.command_handle import CommandExitException @@ -170,22 +165,15 @@ def test_allow_out_transform_injects_headers(sandbox_factory): injected_header = "X-E2B-Test-Token" injected_value = "e2b-transform-value-123" - sandbox = sandbox_factory( - network=SandboxNetworkOpts( - deny_out=[ALL_TRAFFIC], - allow_out=[ - SandboxNetworkRule(host="httpbin.org"), - SandboxNetworkRule( - host="httpbin.org", - transform=[ - SandboxNetworkRuleTransform( - headers={injected_header: injected_value} - ) - ], - ), - ], - ), - ) + network: SandboxNetworkOpts = { + "allow_out": [ + { + "host": "httpbin.org", + "transform": [{"headers": {injected_header: injected_value}}], + }, + ], + } + sandbox = sandbox_factory(network=network) result = sandbox.commands.run("curl -sS --max-time 10 https://httpbin.org/headers") assert result.exit_code == 0 From 3600001624e91bf80d8e77d67a7da1ec077a1cb2 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:47:41 +0200 Subject: [PATCH 04/23] chore(python-sdk): hoist SandboxNetworkRule import in SandboxNetworkConfig Replaces the TYPE_CHECKING / in-method imports of SandboxNetworkRule with a single top-level import. There is no circular-import risk here, so the openapi-python-client guard is unnecessary noise. Note: this file is generated by openapi-python-client and the guard will reappear on the next \`make codegen\` run unless we add a postprocess step. Co-Authored-By: Claude Opus 4.7 --- .../e2b/api/client/models/sandbox_network_config.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py b/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py index 971f7f049d..618c3063ce 100644 --- a/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py +++ b/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py @@ -1,15 +1,12 @@ from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, Union, cast +from typing import Any, TypeVar, Union, cast from attrs import define as _attrs_define from attrs import field as _attrs_field +from ..models.sandbox_network_rule import SandboxNetworkRule from ..types import UNSET, Unset -if TYPE_CHECKING: - from ..models.sandbox_network_rule import SandboxNetworkRule - - T = TypeVar("T", bound="SandboxNetworkConfig") @@ -33,8 +30,6 @@ class SandboxNetworkConfig: additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: - from ..models.sandbox_network_rule import SandboxNetworkRule - allow_out: Union[Unset, list[Union[dict[str, Any], str]]] = UNSET if not isinstance(self.allow_out, Unset): allow_out = [] @@ -70,8 +65,6 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - from ..models.sandbox_network_rule import SandboxNetworkRule - d = dict(src_dict) allow_out = [] _allow_out = d.pop("allowOut", UNSET) From 92f4334f22b35c601bafcf05476b3bbf29c51e7d Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:54:10 +0200 Subject: [PATCH 05/23] chore(python-sdk): drop unnecessary SandboxNetworkConfig alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There is no local SandboxNetworkConfig to collide with — the user-facing equivalent is SandboxNetworkOpts — so the ClientSandboxNetworkConfig rename was dead noise. Co-Authored-By: Claude Opus 4.7 --- packages/python-sdk/e2b/sandbox/sandbox_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index 75cd65d462..606b4d1107 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -9,7 +9,7 @@ ListedSandbox, SandboxDetail, SandboxLifecycle as ClientSandboxLifecycle, - SandboxNetworkConfig as ClientSandboxNetworkConfig, + SandboxNetworkConfig, SandboxNetworkRule as ClientSandboxNetworkRule, SandboxState, ) @@ -159,7 +159,7 @@ def get_auto_resume_enabled(lifecycle: Optional[SandboxLifecycle]) -> Optional[b def from_client_network_config( - network: Union[Unset, ClientSandboxNetworkConfig], + network: Union[Unset, SandboxNetworkConfig], ) -> Optional[SandboxNetworkOpts]: if isinstance(network, Unset): return None From 2f9ebbea14ac9e24bb099a1fee1bfce46e0fb157 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:16:44 +0200 Subject: [PATCH 06/23] fix(python-sdk): preserve UNSET for SandboxNetworkConfig.allow_out The generated from_dict pre-initialized allow_out to [] and then iterated over \`_allow_out or []\`, collapsing the absent-key case (UNSET, which is falsy) into an empty list. Downstream from_client_network_config could then set "allow_out": [] in the user-facing dict, which is semantically "deny all outbound" rather than "field not provided". Now we only build the list when the key is actually present, leaving allow_out as UNSET otherwise. The other oneOf-array fields are not affected because deny_out remains a plain list[str]. Note: this lives in generated code; \`make codegen\` will reintroduce the bug until openapi-python-client is patched or a postprocess step is added. Co-Authored-By: Claude Opus 4.7 --- .../client/models/sandbox_network_config.py | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py b/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py index 618c3063ce..963e3d96a2 100644 --- a/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py +++ b/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py @@ -66,24 +66,28 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: d = dict(src_dict) - allow_out = [] _allow_out = d.pop("allowOut", UNSET) - for allow_out_item_data in _allow_out or []: + allow_out: Union[Unset, list[Union["SandboxNetworkRule", str]]] = UNSET + if not isinstance(_allow_out, Unset): + allow_out = [] + for allow_out_item_data in _allow_out: - def _parse_allow_out_item(data: object) -> Union["SandboxNetworkRule", str]: - try: - if not isinstance(data, dict): - raise TypeError() - allow_out_item_type_1 = SandboxNetworkRule.from_dict(data) + def _parse_allow_out_item( + data: object, + ) -> Union["SandboxNetworkRule", str]: + try: + if not isinstance(data, dict): + raise TypeError() + allow_out_item_type_1 = SandboxNetworkRule.from_dict(data) - return allow_out_item_type_1 - except: # noqa: E722 - pass - return cast(Union["SandboxNetworkRule", str], data) + return allow_out_item_type_1 + except: # noqa: E722 + pass + return cast(Union["SandboxNetworkRule", str], data) - allow_out_item = _parse_allow_out_item(allow_out_item_data) + allow_out_item = _parse_allow_out_item(allow_out_item_data) - allow_out.append(allow_out_item) + allow_out.append(allow_out_item) allow_public_traffic = d.pop("allowPublicTraffic", UNSET) From 9b9eb89a4b8cc4bb5e9d152481780927371d0f9f Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:18:59 +0200 Subject: [PATCH 07/23] fix(python-sdk): handle empty allow_out from generated client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the manual edits to the generated SandboxNetworkConfig (TYPE_CHECKING hoist + UNSET-preserving from_dict) since that file is owned by openapi-python-client and will be overwritten on the next codegen run. Addresses the same underlying bug — generated from_dict pre-inits allow_out to [] and then iterates with \`_allow_out or []\`, so an absent allowOut field deserializes to [] instead of UNSET — by treating empty allow_out as "not provided" inside the hand-written from_client_network_config wrapper. This keeps the public dict from gaining a misleading "allow_out": [] entry. Co-Authored-By: Claude Opus 4.7 --- .../client/models/sandbox_network_config.py | 41 ++++++++++--------- .../python-sdk/e2b/sandbox/sandbox_api.py | 6 ++- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py b/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py index 963e3d96a2..971f7f049d 100644 --- a/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py +++ b/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py @@ -1,12 +1,15 @@ from collections.abc import Mapping -from typing import Any, TypeVar, Union, cast +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast from attrs import define as _attrs_define from attrs import field as _attrs_field -from ..models.sandbox_network_rule import SandboxNetworkRule from ..types import UNSET, Unset +if TYPE_CHECKING: + from ..models.sandbox_network_rule import SandboxNetworkRule + + T = TypeVar("T", bound="SandboxNetworkConfig") @@ -30,6 +33,8 @@ class SandboxNetworkConfig: additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: + from ..models.sandbox_network_rule import SandboxNetworkRule + allow_out: Union[Unset, list[Union[dict[str, Any], str]]] = UNSET if not isinstance(self.allow_out, Unset): allow_out = [] @@ -65,29 +70,27 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.sandbox_network_rule import SandboxNetworkRule + d = dict(src_dict) + allow_out = [] _allow_out = d.pop("allowOut", UNSET) - allow_out: Union[Unset, list[Union["SandboxNetworkRule", str]]] = UNSET - if not isinstance(_allow_out, Unset): - allow_out = [] - for allow_out_item_data in _allow_out: + for allow_out_item_data in _allow_out or []: - def _parse_allow_out_item( - data: object, - ) -> Union["SandboxNetworkRule", str]: - try: - if not isinstance(data, dict): - raise TypeError() - allow_out_item_type_1 = SandboxNetworkRule.from_dict(data) + def _parse_allow_out_item(data: object) -> Union["SandboxNetworkRule", str]: + try: + if not isinstance(data, dict): + raise TypeError() + allow_out_item_type_1 = SandboxNetworkRule.from_dict(data) - return allow_out_item_type_1 - except: # noqa: E722 - pass - return cast(Union["SandboxNetworkRule", str], data) + return allow_out_item_type_1 + except: # noqa: E722 + pass + return cast(Union["SandboxNetworkRule", str], data) - allow_out_item = _parse_allow_out_item(allow_out_item_data) + allow_out_item = _parse_allow_out_item(allow_out_item_data) - allow_out.append(allow_out_item) + allow_out.append(allow_out_item) allow_public_traffic = d.pop("allowPublicTraffic", UNSET) diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index 606b4d1107..717d0bd92a 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -166,7 +166,11 @@ def from_client_network_config( result: SandboxNetworkOpts = {} - if not isinstance(network.allow_out, Unset): + # Truthy check (rather than isinstance(..., Unset)) because the generated + # from_dict pre-initializes allow_out to [] and uses `_allow_out or []`, + # which collapses an absent field into an empty list. Treat both as "no + # allow_out provided" so we don't surface a misleading [] to the caller. + if not isinstance(network.allow_out, Unset) and network.allow_out: result["allow_out"] = [ cast( SandboxNetworkRule, From 3af584f86109357965026bd615d16ef798cf7b06 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:22:25 +0200 Subject: [PATCH 08/23] revert: keep isinstance(network.allow_out, Unset) check in from_client_network_config Co-Authored-By: Claude Opus 4.7 --- packages/python-sdk/e2b/sandbox/sandbox_api.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index 717d0bd92a..606b4d1107 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -166,11 +166,7 @@ def from_client_network_config( result: SandboxNetworkOpts = {} - # Truthy check (rather than isinstance(..., Unset)) because the generated - # from_dict pre-initializes allow_out to [] and uses `_allow_out or []`, - # which collapses an absent field into an empty list. Treat both as "no - # allow_out provided" so we don't surface a misleading [] to the caller. - if not isinstance(network.allow_out, Unset) and network.allow_out: + if not isinstance(network.allow_out, Unset): result["allow_out"] = [ cast( SandboxNetworkRule, From f867b9f8fe87ade0aa93434d92cf071b366e6854 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:33:49 +0200 Subject: [PATCH 09/23] refactor(python-sdk): simplify allow_out conversion via to_dict() Co-Authored-By: Claude Opus 4.7 --- packages/python-sdk/e2b/sandbox/sandbox_api.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index 606b4d1107..42fa9ddab2 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -168,17 +168,7 @@ def from_client_network_config( if not isinstance(network.allow_out, Unset): result["allow_out"] = [ - cast( - SandboxNetworkRule, - { - "host": item.host, - **( - {"transform": [t.to_dict() for t in (item.transform or [])]} - if not isinstance(item.transform, Unset) - else {} - ), - }, - ) + cast(SandboxNetworkRule, item.to_dict()) if isinstance(item, ClientSandboxNetworkRule) else item for item in network.allow_out From 6ade12c432fd82209a39527c5a4b99ecd7b8da9f Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:48:08 +0200 Subject: [PATCH 10/23] refactor(sdk): split network policy and firewall transforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separate the outbound network policy (allowOut/denyOut) from firewall rules. Rules are registered under a top-level firewall map keyed by host but do not grant egress on their own — hosts must still be referenced via allowOut. Selectors accept either a static list or a callback that receives { firewallHosts, allHosts } for composable policies. Co-Authored-By: Claude Opus 4.7 --- packages/js-sdk/src/api/schema.gen.ts | 37 ++-- packages/js-sdk/src/index.ts | 9 +- packages/js-sdk/src/sandbox/sandboxApi.ts | 153 ++++++++++++-- packages/js-sdk/tests/sandbox/network.test.ts | 30 +-- packages/python-sdk/e2b/__init__.py | 18 +- .../e2b/api/client/models/__init__.py | 14 +- .../e2b/api/client/models/new_sandbox.py | 20 ++ .../e2b/api/client/models/sandbox_detail.py | 20 ++ .../e2b/api/client/models/sandbox_firewall.py | 72 +++++++ .../client/models/sandbox_firewall_rule.py | 76 +++++++ ....py => sandbox_firewall_rule_transform.py} | 28 +-- ...andbox_firewall_rule_transform_headers.py} | 10 +- .../client/models/sandbox_network_config.py | 49 +---- .../api/client/models/sandbox_network_rule.py | 88 -------- .../python-sdk/e2b/sandbox/sandbox_api.py | 197 +++++++++++++++--- packages/python-sdk/e2b/sandbox_async/main.py | 8 +- .../e2b/sandbox_async/sandbox_api.py | 9 +- packages/python-sdk/e2b/sandbox_sync/main.py | 8 +- .../e2b/sandbox_sync/sandbox_api.py | 9 +- .../tests/async/sandbox_async/test_network.py | 28 +-- .../tests/sync/sandbox_sync/test_network.py | 28 +-- spec/openapi.yml | 38 ++-- 22 files changed, 655 insertions(+), 294 deletions(-) create mode 100644 packages/python-sdk/e2b/api/client/models/sandbox_firewall.py create mode 100644 packages/python-sdk/e2b/api/client/models/sandbox_firewall_rule.py rename packages/python-sdk/e2b/api/client/models/{sandbox_network_rule_transform.py => sandbox_firewall_rule_transform.py} (64%) rename packages/python-sdk/e2b/api/client/models/{sandbox_network_rule_transform_headers.py => sandbox_firewall_rule_transform_headers.py} (79%) delete mode 100644 packages/python-sdk/e2b/api/client/models/sandbox_network_rule.py diff --git a/packages/js-sdk/src/api/schema.gen.ts b/packages/js-sdk/src/api/schema.gen.ts index 0e61ee04aa..67c276066c 100644 --- a/packages/js-sdk/src/api/schema.gen.ts +++ b/packages/js-sdk/src/api/schema.gen.ts @@ -1966,6 +1966,7 @@ export interface components { autoPause?: boolean; autoResume?: components["schemas"]["SandboxAutoResumeConfig"]; envVars?: components["schemas"]["EnvVars"]; + firewall?: components["schemas"]["SandboxFirewall"]; mcp?: components["schemas"]["Mcp"]; metadata?: components["schemas"]["SandboxMetadata"]; network?: components["schemas"]["SandboxNetworkConfig"]; @@ -2168,6 +2169,7 @@ export interface components { /** @description Access token used for envd communication */ envdAccessToken?: string; envdVersion: components["schemas"]["EnvdVersion"]; + firewall?: components["schemas"]["SandboxFirewall"]; lifecycle?: components["schemas"]["SandboxLifecycle"]; memoryMB: components["schemas"]["MemoryMB"]; metadata?: components["schemas"]["SandboxMetadata"]; @@ -2189,6 +2191,21 @@ export interface components { [key: string]: components["schemas"]["SandboxMetric"]; }; }; + /** @description Map of host to ordered list of firewall rules applied to outbound requests for that host. Registering a host here does not allow egress on its own; the host must also appear in network.allowOut. */ + SandboxFirewall: { + [key: string]: components["schemas"]["SandboxFirewallRule"][]; + }; + /** @description Firewall rule applied to outbound requests matching the host it is registered under. */ + SandboxFirewallRule: { + transform?: components["schemas"]["SandboxFirewallRuleTransform"]; + }; + /** @description Transform applied to egress requests matching a firewall rule. */ + SandboxFirewallRuleTransform: { + /** @description Headers to inject into the outbound request. Values override any headers already present. */ + headers?: { + [key: string]: string; + }; + }; /** @description Sandbox lifecycle policy returned by sandbox info. */ SandboxLifecycle: { /** @description Whether the sandbox can auto-resume. */ @@ -2279,32 +2296,18 @@ export interface components { timestampUnix: number; }; SandboxNetworkConfig: { - /** @description List of allowed egress entries. Each entry is either a CIDR block/IP/host string, or a structured rule with optional per-host transforms. Allowed entries always take precedence over denied ones. */ - allowOut?: (string | components["schemas"]["SandboxNetworkRule"])[]; + /** @description List of allowed CIDR blocks, IP addresses, or hostnames for egress traffic. Allowed entries always take precedence over denied ones. */ + allowOut?: string[]; /** * @description Specify if the sandbox URLs should be accessible only with authentication. * @default true */ allowPublicTraffic?: boolean; - /** @description List of denied CIDR blocks or IP addresses for egress traffic */ + /** @description List of denied CIDR blocks, IP addresses, or hostnames for egress traffic. */ denyOut?: string[]; /** @description Specify host mask which will be used for all sandbox requests */ maskRequestHost?: string; }; - /** @description Structured egress rule matching a host, optionally transforming the request before it leaves the sandbox. */ - SandboxNetworkRule: { - /** @description Host, CIDR block, or IP address the rule applies to. */ - host: string; - /** @description Ordered list of transforms to apply to requests matching this rule. */ - transform?: components["schemas"]["SandboxNetworkRuleTransform"][]; - }; - /** @description Transform applied to egress requests matching a network rule. */ - SandboxNetworkRuleTransform: { - /** @description Headers to inject into the outbound request. Values override any headers already present. */ - headers?: { - [key: string]: string; - }; - }; /** * @description Action taken when the sandbox times out. * @enum {string} diff --git a/packages/js-sdk/src/index.ts b/packages/js-sdk/src/index.ts index 6ceb31ae78..3ecab29e94 100644 --- a/packages/js-sdk/src/index.ts +++ b/packages/js-sdk/src/index.ts @@ -58,9 +58,12 @@ export type { SandboxListOpts, SandboxPaginator, SandboxNetworkOpts, - SandboxNetworkRule, - SandboxNetworkRuleTransform, - SandboxNetworkEntry, + SandboxNetworkInfo, + SandboxNetworkSelector, + SandboxNetworkSelectorContext, + SandboxFirewall, + SandboxFirewallRule, + SandboxFirewallRuleTransform, SandboxLifecycle, SandboxInfoLifecycle, SnapshotInfo, diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 0bb07c0ba1..c75f7f85b7 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -5,6 +5,7 @@ import { DEFAULT_SANDBOX_TIMEOUT_MS, } from '../connectionConfig' import { compareVersions } from 'compare-versions' +import { ALL_TRAFFIC } from './network' import { SandboxNotFoundError, TemplateError } from '../errors' import { timeoutToSeconds } from '../utils' import type { Volume } from '../volume' @@ -34,9 +35,9 @@ export type GitHubMcpServer = { } /** - * Transform applied to outbound requests matching a {@link SandboxNetworkRule}. + * Transform applied to outbound requests matching a {@link SandboxFirewallRule}. */ -export type SandboxNetworkRuleTransform = { +export type SandboxFirewallRuleTransform = { /** * Headers to inject into the outbound request. Values override any headers * already present on the request. @@ -45,40 +46,69 @@ export type SandboxNetworkRuleTransform = { } /** - * Structured egress rule for {@link SandboxNetworkOpts.allowOut}. + * Firewall rule applied to outbound requests matching the host it is + * registered under in {@link SandboxOpts.firewall}. */ -export type SandboxNetworkRule = { - /** Host, CIDR block, or IP address the rule applies to. */ - host: string - /** Ordered list of transforms to apply to requests matching this rule. */ - transform?: SandboxNetworkRuleTransform[] +export type SandboxFirewallRule = { + /** Transform applied to requests matching this rule. */ + transform?: SandboxFirewallRuleTransform } -export type SandboxNetworkEntry = string | SandboxNetworkRule +/** + * Map of host (or CIDR / IP) to ordered list of firewall rules applied to + * outbound requests for that host. Registering a host here does not allow + * egress on its own — the host must also appear in + * {@link SandboxNetworkOpts.allowOut}. + */ +export type SandboxFirewall = Record + +/** + * Context passed to {@link SandboxNetworkOpts.allowOut} and + * {@link SandboxNetworkOpts.denyOut} when they are defined as functions. + */ +export type SandboxNetworkSelectorContext = { + /** Hosts registered in {@link SandboxOpts.firewall}. */ + firewallHosts: string[] + /** All traffic — equivalent to `['0.0.0.0/0']`. */ + allHosts: string[] +} + +/** + * Egress rule list, either a static array of CIDR blocks / IP addresses / + * hostnames, or a callback that receives the resolved firewall hosts and + * returns the same. + */ +export type SandboxNetworkSelector = + | string[] + | ((ctx: SandboxNetworkSelectorContext) => string[]) export type SandboxNetworkOpts = { /** * Allow outbound traffic from the sandbox to the specified addresses. * If `allowOut` is not specified, all outbound traffic is allowed. * - * Each entry is either a string (CIDR block, IP address, or host) or a - * structured {@link SandboxNetworkRule} that can additionally describe - * per-host request transforms (for example, header injection). + * Accepts either a static array of CIDR blocks, IP addresses, or hostnames, + * or a callback that receives `{ firewallHosts, allHosts }` and returns the + * same. `firewallHosts` is the list of hosts registered in + * {@link SandboxOpts.firewall}; `allHosts` is `['0.0.0.0/0']`. * * Examples: - * - Allow traffic to specific addresses: `["1.1.1.1", "8.8.8.0/24"]` - * - Allow a host and inject a header on matching requests: - * `[{ host: "api.openai.com", transform: [{ headers: { Authorization: "Bearer ..." } }] }]` + * - Static list: `["1.1.1.1", "8.8.8.0/24"]` + * - Allow only firewall-registered hosts: + * `({ firewallHosts }) => firewallHosts` */ - allowOut?: SandboxNetworkEntry[] + allowOut?: SandboxNetworkSelector /** * Deny outbound traffic from the sandbox to the specified addresses. * + * Accepts the same shapes as {@link allowOut}. + * * Examples: - * - To deny traffic to a specific addresses: `["1.1.1.1", "8.8.8.0/24"]` + * - Static list: `["1.1.1.1", "8.8.8.0/24"]` + * - Block all egress: `({ allHosts }) => allHosts` */ - denyOut?: string[] + denyOut?: SandboxNetworkSelector /** * Specify if the sandbox URLs should be accessible only with authentication. @@ -94,6 +124,18 @@ export type SandboxNetworkOpts = { maskRequestHost?: string } +/** + * Network configuration as returned by the sandbox info endpoint. Mirrors + * {@link SandboxNetworkOpts} but with `allowOut`/`denyOut` always materialized + * to plain string arrays. + */ +export type SandboxNetworkInfo = { + allowOut?: string[] + denyOut?: string[] + allowPublicTraffic?: boolean + maskRequestHost?: string +} + export type SandboxLifecycle = { /** * Action to take when sandbox timeout is reached. @@ -193,6 +235,29 @@ export interface SandboxOpts extends ConnectionOpts { */ network?: SandboxNetworkOpts + /** + * Per-host firewall rules applied to outbound requests. The keys are hosts + * (or CIDR blocks / IP addresses); the values are ordered lists of rules + * (currently a `transform` describing request modifications). + * + * Registering a host here does not allow egress on its own — the host must + * also appear in {@link SandboxNetworkOpts.allowOut}. Hosts registered here + * are exposed to the `allowOut`/`denyOut` callbacks via `firewallHosts`. + * + * @example + * ```ts + * await Sandbox.create({ + * network: { allowOut: ({ firewallHosts }) => firewallHosts }, + * firewall: { + * 'api.openai.com': [ + * { transform: { headers: { Authorization: `Bearer ${token}` } } }, + * ], + * }, + * }) + * ``` + */ + firewall?: SandboxFirewall + /** * Volume mounts for the sandbox. * @@ -378,7 +443,12 @@ export interface SandboxInfo { /** * Sandbox network configuration. */ - network?: SandboxNetworkOpts + network?: SandboxNetworkInfo + + /** + * Per-host firewall rules registered for the sandbox. + */ + firewall?: SandboxFirewall /** * Sandbox lifecycle configuration. @@ -431,6 +501,47 @@ export interface SandboxMetrics { diskTotal: number } +const ALL_TRAFFIC_HOSTS: readonly string[] = [ALL_TRAFFIC] + +function resolveNetworkSelector( + selector: SandboxNetworkSelector | undefined, + firewallHosts: string[] +): string[] | undefined { + if (selector === undefined) { + return undefined + } + + if (typeof selector === 'function') { + return selector({ firewallHosts, allHosts: [...ALL_TRAFFIC_HOSTS] }) + } + + return selector +} + +function buildNetworkBody( + network: SandboxNetworkOpts | undefined, + firewall: SandboxFirewall | undefined +): components['schemas']['SandboxNetworkConfig'] | undefined { + if (!network) { + return undefined + } + + const firewallHosts = firewall ? Object.keys(firewall) : [] + const allowOut = resolveNetworkSelector(network.allowOut, firewallHosts) + const denyOut = resolveNetworkSelector(network.denyOut, firewallHosts) + + return { + ...(allowOut !== undefined ? { allowOut } : {}), + ...(denyOut !== undefined ? { denyOut } : {}), + ...(network.allowPublicTraffic !== undefined + ? { allowPublicTraffic: network.allowPublicTraffic } + : {}), + ...(network.maskRequestHost !== undefined + ? { maskRequestHost: network.maskRequestHost } + : {}), + } +} + function getLifecycle( opts?: Pick ): SandboxLifecycle { @@ -647,6 +758,7 @@ export class SandboxApi { maskRequestHost: res.data.network.maskRequestHost, } : undefined, + firewall: res.data.firewall ?? undefined, lifecycle: res.data.lifecycle ? { onTimeout: res.data.lifecycle.onTimeout, @@ -821,7 +933,8 @@ export class SandboxApi { timeout: timeoutToSeconds(timeoutMs), secure: opts?.secure ?? true, allow_internet_access: opts?.allowInternetAccess ?? true, - network: opts?.network, + network: buildNetworkBody(opts?.network, opts?.firewall), + ...(opts?.firewall ? { firewall: opts.firewall } : {}), ...(autoPause !== undefined ? { autoPause } : {}), ...(autoResumeEnabled !== undefined ? { autoResume: { enabled: autoResumeEnabled } } diff --git a/packages/js-sdk/tests/sandbox/network.test.ts b/packages/js-sdk/tests/sandbox/network.test.ts index bd32a28f08..b4c9e91bfd 100644 --- a/packages/js-sdk/tests/sandbox/network.test.ts +++ b/packages/js-sdk/tests/sandbox/network.test.ts @@ -1,13 +1,13 @@ import { assert, expect, describe } from 'vitest' -import { CommandExitError, ALL_TRAFFIC } from '../../src' +import { CommandExitError } from '../../src' import { sandboxTest, isDebug } from '../setup.js' describe('allow only 1.1.1.1', () => { sandboxTest.scoped({ sandboxOpts: { network: { - denyOut: [ALL_TRAFFIC], + denyOut: ({ allHosts }) => allHosts, allowOut: ['1.1.1.1'], }, }, @@ -62,17 +62,17 @@ describe('deny specific IP address', () => { ) }) -describe('deny all traffic using allTraffic helper', () => { +describe('deny all traffic using allHosts selector', () => { sandboxTest.scoped({ sandboxOpts: { network: { - denyOut: [ALL_TRAFFIC], + denyOut: ({ allHosts }) => allHosts, }, }, }) sandboxTest.skipIf(isDebug)( - 'deny all traffic using allTraffic helper', + 'deny all traffic using allHosts selector', async ({ sandbox }) => { // Test that all traffic is denied await expect( @@ -94,7 +94,7 @@ describe('allow takes precedence over deny', () => { sandboxTest.scoped({ sandboxOpts: { network: { - denyOut: [ALL_TRAFFIC], + denyOut: ({ allHosts }) => allHosts, allowOut: ['1.1.1.1', '8.8.8.8'], }, }, @@ -193,23 +193,23 @@ describe('allowPublicTraffic=true', () => { ) }) -describe('allowOut transform injects headers', () => { +describe('firewall transform injects headers', () => { const injectedHeader = 'X-E2B-Test-Token' const injectedValue = 'e2b-transform-value-123' sandboxTest.scoped({ sandboxOpts: { network: { - allowOut: [ + allowOut: ({ firewallHosts }) => firewallHosts, + }, + firewall: { + 'httpbin.org': [ { - host: 'httpbin.org', - transform: [ - { - headers: { - [injectedHeader]: injectedValue, - }, + transform: { + headers: { + [injectedHeader]: injectedValue, }, - ], + }, }, ], }, diff --git a/packages/python-sdk/e2b/__init__.py b/packages/python-sdk/e2b/__init__.py index 1185839245..1019809f8e 100644 --- a/packages/python-sdk/e2b/__init__.py +++ b/packages/python-sdk/e2b/__init__.py @@ -75,14 +75,17 @@ GitHubMcpServer, GitHubMcpServerConfig, McpServer, + SandboxFirewall, + SandboxFirewallRule, + SandboxFirewallRuleTransform, SandboxInfo, SandboxInfoLifecycle, SandboxMetrics, SandboxLifecycle, - SandboxNetworkEntry, + SandboxNetworkInfo, SandboxNetworkOpts, - SandboxNetworkRule, - SandboxNetworkRuleTransform, + SandboxNetworkSelector, + SandboxNetworkSelectorContext, SandboxQuery, SandboxState, SnapshotInfo, @@ -186,9 +189,12 @@ "FileType", # Network "SandboxNetworkOpts", - "SandboxNetworkRule", - "SandboxNetworkRuleTransform", - "SandboxNetworkEntry", + "SandboxNetworkInfo", + "SandboxNetworkSelector", + "SandboxNetworkSelectorContext", + "SandboxFirewall", + "SandboxFirewallRule", + "SandboxFirewallRuleTransform", "SandboxLifecycle", "ALL_TRAFFIC", # Snapshot diff --git a/packages/python-sdk/e2b/api/client/models/__init__.py b/packages/python-sdk/e2b/api/client/models/__init__.py index f9c527d057..c5e872afb0 100644 --- a/packages/python-sdk/e2b/api/client/models/__init__.py +++ b/packages/python-sdk/e2b/api/client/models/__init__.py @@ -46,6 +46,10 @@ from .sandbox import Sandbox from .sandbox_auto_resume_config import SandboxAutoResumeConfig from .sandbox_detail import SandboxDetail +from .sandbox_firewall import SandboxFirewall +from .sandbox_firewall_rule import SandboxFirewallRule +from .sandbox_firewall_rule_transform import SandboxFirewallRuleTransform +from .sandbox_firewall_rule_transform_headers import SandboxFirewallRuleTransformHeaders from .sandbox_lifecycle import SandboxLifecycle from .sandbox_log import SandboxLog from .sandbox_log_entry import SandboxLogEntry @@ -54,9 +58,6 @@ from .sandbox_logs_v2_response import SandboxLogsV2Response from .sandbox_metric import SandboxMetric from .sandbox_network_config import SandboxNetworkConfig -from .sandbox_network_rule import SandboxNetworkRule -from .sandbox_network_rule_transform import SandboxNetworkRuleTransform -from .sandbox_network_rule_transform_headers import SandboxNetworkRuleTransformHeaders from .sandbox_on_timeout import SandboxOnTimeout from .sandbox_state import SandboxState from .sandbox_volume_mount import SandboxVolumeMount @@ -133,6 +134,10 @@ "SandboxAutoResumeConfig", "SandboxDetail", "SandboxesWithMetrics", + "SandboxFirewall", + "SandboxFirewallRule", + "SandboxFirewallRuleTransform", + "SandboxFirewallRuleTransformHeaders", "SandboxLifecycle", "SandboxLog", "SandboxLogEntry", @@ -141,9 +146,6 @@ "SandboxLogsV2Response", "SandboxMetric", "SandboxNetworkConfig", - "SandboxNetworkRule", - "SandboxNetworkRuleTransform", - "SandboxNetworkRuleTransformHeaders", "SandboxOnTimeout", "SandboxState", "SandboxVolumeMount", diff --git a/packages/python-sdk/e2b/api/client/models/new_sandbox.py b/packages/python-sdk/e2b/api/client/models/new_sandbox.py index 8703ef5692..0d7c808ddf 100644 --- a/packages/python-sdk/e2b/api/client/models/new_sandbox.py +++ b/packages/python-sdk/e2b/api/client/models/new_sandbox.py @@ -9,6 +9,7 @@ if TYPE_CHECKING: from ..models.mcp_type_0 import McpType0 from ..models.sandbox_auto_resume_config import SandboxAutoResumeConfig + from ..models.sandbox_firewall import SandboxFirewall from ..models.sandbox_network_config import SandboxNetworkConfig from ..models.sandbox_volume_mount import SandboxVolumeMount @@ -26,6 +27,9 @@ class NewSandbox: auto_pause (Union[Unset, bool]): Automatically pauses the sandbox after the timeout Default: False. auto_resume (Union[Unset, SandboxAutoResumeConfig]): Auto-resume configuration for paused sandboxes. env_vars (Union[Unset, Any]): + firewall (Union[Unset, SandboxFirewall]): Map of host to ordered list of firewall rules applied to outbound + requests for that host. Registering a host here does not allow egress on its own; the host must also appear in + network.allowOut. mcp (Union['McpType0', None, Unset]): MCP configuration for the sandbox metadata (Union[Unset, Any]): network (Union[Unset, SandboxNetworkConfig]): @@ -39,6 +43,7 @@ class NewSandbox: auto_pause: Union[Unset, bool] = False auto_resume: Union[Unset, "SandboxAutoResumeConfig"] = UNSET env_vars: Union[Unset, Any] = UNSET + firewall: Union[Unset, "SandboxFirewall"] = UNSET mcp: Union["McpType0", None, Unset] = UNSET metadata: Union[Unset, Any] = UNSET network: Union[Unset, "SandboxNetworkConfig"] = UNSET @@ -62,6 +67,10 @@ def to_dict(self) -> dict[str, Any]: env_vars = self.env_vars + firewall: Union[Unset, dict[str, Any]] = UNSET + if not isinstance(self.firewall, Unset): + firewall = self.firewall.to_dict() + mcp: Union[None, Unset, dict[str, Any]] if isinstance(self.mcp, Unset): mcp = UNSET @@ -102,6 +111,8 @@ def to_dict(self) -> dict[str, Any]: field_dict["autoResume"] = auto_resume if env_vars is not UNSET: field_dict["envVars"] = env_vars + if firewall is not UNSET: + field_dict["firewall"] = firewall if mcp is not UNSET: field_dict["mcp"] = mcp if metadata is not UNSET: @@ -121,6 +132,7 @@ def to_dict(self) -> dict[str, Any]: def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: from ..models.mcp_type_0 import McpType0 from ..models.sandbox_auto_resume_config import SandboxAutoResumeConfig + from ..models.sandbox_firewall import SandboxFirewall from ..models.sandbox_network_config import SandboxNetworkConfig from ..models.sandbox_volume_mount import SandboxVolumeMount @@ -140,6 +152,13 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: env_vars = d.pop("envVars", UNSET) + _firewall = d.pop("firewall", UNSET) + firewall: Union[Unset, SandboxFirewall] + if isinstance(_firewall, Unset): + firewall = UNSET + else: + firewall = SandboxFirewall.from_dict(_firewall) + def _parse_mcp(data: object) -> Union["McpType0", None, Unset]: if data is None: return data @@ -183,6 +202,7 @@ def _parse_mcp(data: object) -> Union["McpType0", None, Unset]: auto_pause=auto_pause, auto_resume=auto_resume, env_vars=env_vars, + firewall=firewall, mcp=mcp, metadata=metadata, network=network, diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_detail.py b/packages/python-sdk/e2b/api/client/models/sandbox_detail.py index 1078d2b7aa..7fee681b27 100644 --- a/packages/python-sdk/e2b/api/client/models/sandbox_detail.py +++ b/packages/python-sdk/e2b/api/client/models/sandbox_detail.py @@ -10,6 +10,7 @@ from ..types import UNSET, Unset if TYPE_CHECKING: + from ..models.sandbox_firewall import SandboxFirewall from ..models.sandbox_lifecycle import SandboxLifecycle from ..models.sandbox_network_config import SandboxNetworkConfig from ..models.sandbox_volume_mount import SandboxVolumeMount @@ -37,6 +38,9 @@ class SandboxDetail: the sandbox. Null means it was not explicitly set. domain (Union[None, Unset, str]): Base domain where the sandbox traffic is accessible envd_access_token (Union[Unset, str]): Access token used for envd communication + firewall (Union[Unset, SandboxFirewall]): Map of host to ordered list of firewall rules applied to outbound + requests for that host. Registering a host here does not allow egress on its own; the host must also appear in + network.allowOut. lifecycle (Union[Unset, SandboxLifecycle]): Sandbox lifecycle policy returned by sandbox info. metadata (Union[Unset, Any]): network (Union[Unset, SandboxNetworkConfig]): @@ -57,6 +61,7 @@ class SandboxDetail: allow_internet_access: Union[None, Unset, bool] = UNSET domain: Union[None, Unset, str] = UNSET envd_access_token: Union[Unset, str] = UNSET + firewall: Union[Unset, "SandboxFirewall"] = UNSET lifecycle: Union[Unset, "SandboxLifecycle"] = UNSET metadata: Union[Unset, Any] = UNSET network: Union[Unset, "SandboxNetworkConfig"] = UNSET @@ -100,6 +105,10 @@ def to_dict(self) -> dict[str, Any]: envd_access_token = self.envd_access_token + firewall: Union[Unset, dict[str, Any]] = UNSET + if not isinstance(self.firewall, Unset): + firewall = self.firewall.to_dict() + lifecycle: Union[Unset, dict[str, Any]] = UNSET if not isinstance(self.lifecycle, Unset): lifecycle = self.lifecycle.to_dict() @@ -141,6 +150,8 @@ def to_dict(self) -> dict[str, Any]: field_dict["domain"] = domain if envd_access_token is not UNSET: field_dict["envdAccessToken"] = envd_access_token + if firewall is not UNSET: + field_dict["firewall"] = firewall if lifecycle is not UNSET: field_dict["lifecycle"] = lifecycle if metadata is not UNSET: @@ -154,6 +165,7 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.sandbox_firewall import SandboxFirewall from ..models.sandbox_lifecycle import SandboxLifecycle from ..models.sandbox_network_config import SandboxNetworkConfig from ..models.sandbox_volume_mount import SandboxVolumeMount @@ -203,6 +215,13 @@ def _parse_domain(data: object) -> Union[None, Unset, str]: envd_access_token = d.pop("envdAccessToken", UNSET) + _firewall = d.pop("firewall", UNSET) + firewall: Union[Unset, SandboxFirewall] + if isinstance(_firewall, Unset): + firewall = UNSET + else: + firewall = SandboxFirewall.from_dict(_firewall) + _lifecycle = d.pop("lifecycle", UNSET) lifecycle: Union[Unset, SandboxLifecycle] if isinstance(_lifecycle, Unset): @@ -241,6 +260,7 @@ def _parse_domain(data: object) -> Union[None, Unset, str]: allow_internet_access=allow_internet_access, domain=domain, envd_access_token=envd_access_token, + firewall=firewall, lifecycle=lifecycle, metadata=metadata, network=network, diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_firewall.py b/packages/python-sdk/e2b/api/client/models/sandbox_firewall.py new file mode 100644 index 0000000000..ef63b35ad5 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_firewall.py @@ -0,0 +1,72 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.sandbox_firewall_rule import SandboxFirewallRule + + +T = TypeVar("T", bound="SandboxFirewall") + + +@_attrs_define +class SandboxFirewall: + """Map of host to ordered list of firewall rules applied to outbound requests for that host. Registering a host here + does not allow egress on its own; the host must also appear in network.allowOut. + + """ + + additional_properties: dict[str, list["SandboxFirewallRule"]] = _attrs_field( + init=False, factory=dict + ) + + def to_dict(self) -> dict[str, Any]: + field_dict: dict[str, Any] = {} + for prop_name, prop in self.additional_properties.items(): + field_dict[prop_name] = [] + for additional_property_item_data in prop: + additional_property_item = additional_property_item_data.to_dict() + field_dict[prop_name].append(additional_property_item) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.sandbox_firewall_rule import SandboxFirewallRule + + d = dict(src_dict) + sandbox_firewall = cls() + + additional_properties = {} + for prop_name, prop_dict in d.items(): + additional_property = [] + _additional_property = prop_dict + for additional_property_item_data in _additional_property: + additional_property_item = SandboxFirewallRule.from_dict( + additional_property_item_data + ) + + additional_property.append(additional_property_item) + + additional_properties[prop_name] = additional_property + + sandbox_firewall.additional_properties = additional_properties + return sandbox_firewall + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> list["SandboxFirewallRule"]: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: list["SandboxFirewallRule"]) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_firewall_rule.py b/packages/python-sdk/e2b/api/client/models/sandbox_firewall_rule.py new file mode 100644 index 0000000000..16c6c2ce04 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_firewall_rule.py @@ -0,0 +1,76 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.sandbox_firewall_rule_transform import SandboxFirewallRuleTransform + + +T = TypeVar("T", bound="SandboxFirewallRule") + + +@_attrs_define +class SandboxFirewallRule: + """Firewall rule applied to outbound requests matching the host it is registered under. + + Attributes: + transform (Union[Unset, SandboxFirewallRuleTransform]): Transform applied to egress requests matching a firewall + rule. + """ + + transform: Union[Unset, "SandboxFirewallRuleTransform"] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + transform: Union[Unset, dict[str, Any]] = UNSET + if not isinstance(self.transform, Unset): + transform = self.transform.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if transform is not UNSET: + field_dict["transform"] = transform + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.sandbox_firewall_rule_transform import ( + SandboxFirewallRuleTransform, + ) + + d = dict(src_dict) + _transform = d.pop("transform", UNSET) + transform: Union[Unset, SandboxFirewallRuleTransform] + if isinstance(_transform, Unset): + transform = UNSET + else: + transform = SandboxFirewallRuleTransform.from_dict(_transform) + + sandbox_firewall_rule = cls( + transform=transform, + ) + + sandbox_firewall_rule.additional_properties = d + return sandbox_firewall_rule + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_network_rule_transform.py b/packages/python-sdk/e2b/api/client/models/sandbox_firewall_rule_transform.py similarity index 64% rename from packages/python-sdk/e2b/api/client/models/sandbox_network_rule_transform.py rename to packages/python-sdk/e2b/api/client/models/sandbox_firewall_rule_transform.py index 9fad5cf001..494921f18d 100644 --- a/packages/python-sdk/e2b/api/client/models/sandbox_network_rule_transform.py +++ b/packages/python-sdk/e2b/api/client/models/sandbox_firewall_rule_transform.py @@ -7,24 +7,24 @@ from ..types import UNSET, Unset if TYPE_CHECKING: - from ..models.sandbox_network_rule_transform_headers import ( - SandboxNetworkRuleTransformHeaders, + from ..models.sandbox_firewall_rule_transform_headers import ( + SandboxFirewallRuleTransformHeaders, ) -T = TypeVar("T", bound="SandboxNetworkRuleTransform") +T = TypeVar("T", bound="SandboxFirewallRuleTransform") @_attrs_define -class SandboxNetworkRuleTransform: - """Transform applied to egress requests matching a network rule. +class SandboxFirewallRuleTransform: + """Transform applied to egress requests matching a firewall rule. Attributes: - headers (Union[Unset, SandboxNetworkRuleTransformHeaders]): Headers to inject into the outbound request. Values + headers (Union[Unset, SandboxFirewallRuleTransformHeaders]): Headers to inject into the outbound request. Values override any headers already present. """ - headers: Union[Unset, "SandboxNetworkRuleTransformHeaders"] = UNSET + headers: Union[Unset, "SandboxFirewallRuleTransformHeaders"] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: @@ -42,24 +42,24 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - from ..models.sandbox_network_rule_transform_headers import ( - SandboxNetworkRuleTransformHeaders, + from ..models.sandbox_firewall_rule_transform_headers import ( + SandboxFirewallRuleTransformHeaders, ) d = dict(src_dict) _headers = d.pop("headers", UNSET) - headers: Union[Unset, SandboxNetworkRuleTransformHeaders] + headers: Union[Unset, SandboxFirewallRuleTransformHeaders] if isinstance(_headers, Unset): headers = UNSET else: - headers = SandboxNetworkRuleTransformHeaders.from_dict(_headers) + headers = SandboxFirewallRuleTransformHeaders.from_dict(_headers) - sandbox_network_rule_transform = cls( + sandbox_firewall_rule_transform = cls( headers=headers, ) - sandbox_network_rule_transform.additional_properties = d - return sandbox_network_rule_transform + sandbox_firewall_rule_transform.additional_properties = d + return sandbox_firewall_rule_transform @property def additional_keys(self) -> list[str]: diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_network_rule_transform_headers.py b/packages/python-sdk/e2b/api/client/models/sandbox_firewall_rule_transform_headers.py similarity index 79% rename from packages/python-sdk/e2b/api/client/models/sandbox_network_rule_transform_headers.py rename to packages/python-sdk/e2b/api/client/models/sandbox_firewall_rule_transform_headers.py index 6c79edd9c8..3577aba5a2 100644 --- a/packages/python-sdk/e2b/api/client/models/sandbox_network_rule_transform_headers.py +++ b/packages/python-sdk/e2b/api/client/models/sandbox_firewall_rule_transform_headers.py @@ -4,11 +4,11 @@ from attrs import define as _attrs_define from attrs import field as _attrs_field -T = TypeVar("T", bound="SandboxNetworkRuleTransformHeaders") +T = TypeVar("T", bound="SandboxFirewallRuleTransformHeaders") @_attrs_define -class SandboxNetworkRuleTransformHeaders: +class SandboxFirewallRuleTransformHeaders: """Headers to inject into the outbound request. Values override any headers already present.""" additional_properties: dict[str, str] = _attrs_field(init=False, factory=dict) @@ -22,10 +22,10 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: d = dict(src_dict) - sandbox_network_rule_transform_headers = cls() + sandbox_firewall_rule_transform_headers = cls() - sandbox_network_rule_transform_headers.additional_properties = d - return sandbox_network_rule_transform_headers + sandbox_firewall_rule_transform_headers.additional_properties = d + return sandbox_firewall_rule_transform_headers @property def additional_keys(self) -> list[str]: diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py b/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py index 971f7f049d..d10d85f175 100644 --- a/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py +++ b/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py @@ -1,15 +1,11 @@ from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, Union, cast +from typing import Any, TypeVar, Union, cast from attrs import define as _attrs_define from attrs import field as _attrs_field from ..types import UNSET, Unset -if TYPE_CHECKING: - from ..models.sandbox_network_rule import SandboxNetworkRule - - T = TypeVar("T", bound="SandboxNetworkConfig") @@ -17,34 +13,24 @@ class SandboxNetworkConfig: """ Attributes: - allow_out (Union[Unset, list[Union['SandboxNetworkRule', str]]]): List of allowed egress entries. Each entry is - either a CIDR block/IP/host string, or a structured rule with optional per-host transforms. Allowed entries - always take precedence over denied ones. + allow_out (Union[Unset, list[str]]): List of allowed CIDR blocks, IP addresses, or hostnames for egress traffic. + Allowed entries always take precedence over denied ones. allow_public_traffic (Union[Unset, bool]): Specify if the sandbox URLs should be accessible only with authentication. Default: True. - deny_out (Union[Unset, list[str]]): List of denied CIDR blocks or IP addresses for egress traffic + deny_out (Union[Unset, list[str]]): List of denied CIDR blocks, IP addresses, or hostnames for egress traffic. mask_request_host (Union[Unset, str]): Specify host mask which will be used for all sandbox requests """ - allow_out: Union[Unset, list[Union["SandboxNetworkRule", str]]] = UNSET + allow_out: Union[Unset, list[str]] = UNSET allow_public_traffic: Union[Unset, bool] = True deny_out: Union[Unset, list[str]] = UNSET mask_request_host: Union[Unset, str] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: - from ..models.sandbox_network_rule import SandboxNetworkRule - - allow_out: Union[Unset, list[Union[dict[str, Any], str]]] = UNSET + allow_out: Union[Unset, list[str]] = UNSET if not isinstance(self.allow_out, Unset): - allow_out = [] - for allow_out_item_data in self.allow_out: - allow_out_item: Union[dict[str, Any], str] - if isinstance(allow_out_item_data, SandboxNetworkRule): - allow_out_item = allow_out_item_data.to_dict() - else: - allow_out_item = allow_out_item_data - allow_out.append(allow_out_item) + allow_out = self.allow_out allow_public_traffic = self.allow_public_traffic @@ -70,27 +56,8 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - from ..models.sandbox_network_rule import SandboxNetworkRule - d = dict(src_dict) - allow_out = [] - _allow_out = d.pop("allowOut", UNSET) - for allow_out_item_data in _allow_out or []: - - def _parse_allow_out_item(data: object) -> Union["SandboxNetworkRule", str]: - try: - if not isinstance(data, dict): - raise TypeError() - allow_out_item_type_1 = SandboxNetworkRule.from_dict(data) - - return allow_out_item_type_1 - except: # noqa: E722 - pass - return cast(Union["SandboxNetworkRule", str], data) - - allow_out_item = _parse_allow_out_item(allow_out_item_data) - - allow_out.append(allow_out_item) + allow_out = cast(list[str], d.pop("allowOut", UNSET)) allow_public_traffic = d.pop("allowPublicTraffic", UNSET) diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_network_rule.py b/packages/python-sdk/e2b/api/client/models/sandbox_network_rule.py deleted file mode 100644 index aef0bfff15..0000000000 --- a/packages/python-sdk/e2b/api/client/models/sandbox_network_rule.py +++ /dev/null @@ -1,88 +0,0 @@ -from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, Union - -from attrs import define as _attrs_define -from attrs import field as _attrs_field - -from ..types import UNSET, Unset - -if TYPE_CHECKING: - from ..models.sandbox_network_rule_transform import SandboxNetworkRuleTransform - - -T = TypeVar("T", bound="SandboxNetworkRule") - - -@_attrs_define -class SandboxNetworkRule: - """Structured egress rule matching a host, optionally transforming the request before it leaves the sandbox. - - Attributes: - host (str): Host, CIDR block, or IP address the rule applies to. - transform (Union[Unset, list['SandboxNetworkRuleTransform']]): Ordered list of transforms to apply to requests - matching this rule. - """ - - host: str - transform: Union[Unset, list["SandboxNetworkRuleTransform"]] = UNSET - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - host = self.host - - transform: Union[Unset, list[dict[str, Any]]] = UNSET - if not isinstance(self.transform, Unset): - transform = [] - for transform_item_data in self.transform: - transform_item = transform_item_data.to_dict() - transform.append(transform_item) - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "host": host, - } - ) - if transform is not UNSET: - field_dict["transform"] = transform - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - from ..models.sandbox_network_rule_transform import SandboxNetworkRuleTransform - - d = dict(src_dict) - host = d.pop("host") - - transform = [] - _transform = d.pop("transform", UNSET) - for transform_item_data in _transform or []: - transform_item = SandboxNetworkRuleTransform.from_dict(transform_item_data) - - transform.append(transform_item) - - sandbox_network_rule = cls( - host=host, - transform=transform, - ) - - sandbox_network_rule.additional_properties = d - return sandbox_network_rule - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index 42fa9ddab2..d068653c8f 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -1,6 +1,6 @@ from dataclasses import dataclass, field from datetime import datetime -from typing import Any, Dict, List, Literal, Optional, TypedDict, Union, cast +from typing import Any, Callable, Dict, List, Literal, Optional, TypedDict, Union, cast from typing_extensions import NotRequired, Unpack @@ -8,14 +8,15 @@ from e2b.api.client.models import ( ListedSandbox, SandboxDetail, + SandboxFirewall as ClientSandboxFirewall, SandboxLifecycle as ClientSandboxLifecycle, SandboxNetworkConfig, - SandboxNetworkRule as ClientSandboxNetworkRule, SandboxState, ) from e2b.api.client.types import Unset from e2b.connection_config import ApiParams from e2b.sandbox.mcp import McpServer as BaseMcpServer +from e2b.sandbox.network import ALL_TRAFFIC class GitHubMcpServerConfig(TypedDict): @@ -46,9 +47,9 @@ class GitHubMcpServerConfig(TypedDict): McpServer = Union[BaseMcpServer, GitHubMcpServer] -class SandboxNetworkRuleTransform(TypedDict): +class SandboxFirewallRuleTransform(TypedDict): """ - Transform applied to outbound requests matching a `SandboxNetworkRule`. + Transform applied to outbound requests matching a `SandboxFirewallRule`. """ headers: NotRequired[Dict[str, str]] @@ -58,19 +59,46 @@ class SandboxNetworkRuleTransform(TypedDict): """ -class SandboxNetworkRule(TypedDict): +class SandboxFirewallRule(TypedDict): """ - Structured egress rule for `SandboxNetworkOpts.allow_out`. + Firewall rule applied to outbound requests matching the host it is + registered under in `firewall`. """ - host: str - """Host, CIDR block, or IP address the rule applies to.""" + transform: NotRequired[SandboxFirewallRuleTransform] + """Transform applied to requests matching this rule.""" - transform: NotRequired[List[SandboxNetworkRuleTransform]] - """Ordered list of transforms to apply to requests matching this rule.""" +SandboxFirewall = Dict[str, List[SandboxFirewallRule]] +""" +Map of host (or CIDR / IP) to ordered list of firewall rules applied to +outbound requests for that host. Registering a host here does not allow egress +on its own — the host must also appear in ``SandboxNetworkOpts.allow_out``. +""" -SandboxNetworkEntry = Union[str, SandboxNetworkRule] + +@dataclass(frozen=True) +class SandboxNetworkSelectorContext: + """ + Context passed to ``allow_out``/``deny_out`` callables. + """ + + firewall_hosts: List[str] + """Hosts registered in the top-level ``firewall`` argument.""" + + all_hosts: List[str] + """All traffic — equivalent to ``["0.0.0.0/0"]``.""" + + +SandboxNetworkSelector = Union[ + List[str], + Callable[[SandboxNetworkSelectorContext], List[str]], +] +""" +Egress rule list, either a static list of CIDR blocks / IP addresses / +hostnames, or a callable that receives a :class:`SandboxNetworkSelectorContext` +and returns the same. +""" class SandboxNetworkOpts(TypedDict): @@ -78,27 +106,32 @@ class SandboxNetworkOpts(TypedDict): Sandbox network configuration options. """ - allow_out: NotRequired[List[SandboxNetworkEntry]] + allow_out: NotRequired[SandboxNetworkSelector] """ Allow outbound traffic from the sandbox to the specified addresses. - If `allow_out` is not specified, all outbound traffic is allowed. + If ``allow_out`` is not specified, all outbound traffic is allowed. - Each entry is either a string (CIDR block, IP address, or host) or a - structured `SandboxNetworkRule` that can additionally describe per-host - request transforms (for example, header injection). + Accepts either a static list of CIDR blocks / IP addresses / hostnames, or + a callable that receives a :class:`SandboxNetworkSelectorContext` and + returns the same. ``ctx.firewall_hosts`` is the list of hosts registered + in the top-level ``firewall`` argument; ``ctx.all_hosts`` is + ``["0.0.0.0/0"]``. Examples: - - Allow traffic to specific addresses: `["1.1.1.1", "8.8.8.0/24"]` - - Allow a host and inject a header on matching requests: - `[{"host": "api.openai.com", "transform": [{"headers": {"Authorization": "Bearer ..."}}]}]` + - Static list: ``["1.1.1.1", "8.8.8.0/24"]`` + - Allow only firewall-registered hosts: + ``lambda ctx: ctx.firewall_hosts`` """ - deny_out: NotRequired[List[str]] + deny_out: NotRequired[SandboxNetworkSelector] """ Deny outbound traffic from the sandbox to the specified addresses. + Accepts the same shapes as ``allow_out``. + Examples: - - To deny traffic to specific addresses: `["1.1.1.1", "8.8.8.0/24"]` + - Static list: ``["1.1.1.1", "8.8.8.0/24"]`` + - Block all egress: ``lambda ctx: ctx.all_hosts`` """ allow_public_traffic: NotRequired[bool] @@ -117,6 +150,19 @@ class SandboxNetworkOpts(TypedDict): """ +class SandboxNetworkInfo(TypedDict, total=False): + """ + Network configuration as returned by the sandbox info endpoint. + Mirrors :class:`SandboxNetworkOpts` but with ``allow_out``/``deny_out`` + always materialized to plain string lists. + """ + + allow_out: List[str] + deny_out: List[str] + allow_public_traffic: bool + mask_request_host: str + + class SandboxLifecycle(TypedDict): """ Sandbox lifecycle configuration; defines post-timeout behavior and auto-resume settings. @@ -151,6 +197,86 @@ class SandboxInfoLifecycle(TypedDict): """ +_ALL_TRAFFIC_HOSTS: List[str] = [ALL_TRAFFIC] + + +def _resolve_network_selector( + selector: Optional[SandboxNetworkSelector], + firewall_hosts: List[str], +) -> Optional[List[str]]: + if selector is None: + return None + + if callable(selector): + ctx = SandboxNetworkSelectorContext( + firewall_hosts=firewall_hosts, + all_hosts=list(_ALL_TRAFFIC_HOSTS), + ) + return list(selector(ctx)) + + return list(selector) + + +def build_network_config( + network: Optional[SandboxNetworkOpts], + firewall: Optional[SandboxFirewall], +) -> Optional[Dict[str, Any]]: + """Resolve a :class:`SandboxNetworkOpts` into the dict the API expects.""" + if network is None: + return None + + firewall_hosts = list(firewall.keys()) if firewall else [] + allow_out = _resolve_network_selector(network.get("allow_out"), firewall_hosts) + deny_out = _resolve_network_selector(network.get("deny_out"), firewall_hosts) + + body: Dict[str, Any] = {} + if allow_out is not None: + body["allow_out"] = allow_out + if deny_out is not None: + body["deny_out"] = deny_out + if "allow_public_traffic" in network: + body["allow_public_traffic"] = network["allow_public_traffic"] + if "mask_request_host" in network: + body["mask_request_host"] = network["mask_request_host"] + + return body + + +def build_firewall_config( + firewall: Optional[SandboxFirewall], +) -> Optional[ClientSandboxFirewall]: + """Convert a :class:`SandboxFirewall` into the generated client model.""" + if firewall is None: + return None + + from e2b.api.client.models import ( + SandboxFirewallRule as ClientSandboxFirewallRule, + SandboxFirewallRuleTransform as ClientSandboxFirewallRuleTransform, + SandboxFirewallRuleTransformHeaders as ClientSandboxFirewallRuleTransformHeaders, + ) + + client_firewall = ClientSandboxFirewall() + for host, rules in firewall.items(): + client_rules: List[ClientSandboxFirewallRule] = [] + for rule in rules: + transform = rule.get("transform") + if transform is None: + client_rules.append(ClientSandboxFirewallRule()) + continue + + client_transform = ClientSandboxFirewallRuleTransform() + headers = transform.get("headers") + if headers: + client_headers = ClientSandboxFirewallRuleTransformHeaders() + client_headers.additional_properties = dict(headers) + client_transform.headers = client_headers + + client_rules.append(ClientSandboxFirewallRule(transform=client_transform)) + client_firewall.additional_properties[host] = client_rules + + return client_firewall + + def get_auto_resume_enabled(lifecycle: Optional[SandboxLifecycle]) -> Optional[bool]: if lifecycle is None or lifecycle.get("on_timeout") != "pause": return None @@ -160,19 +286,14 @@ def get_auto_resume_enabled(lifecycle: Optional[SandboxLifecycle]) -> Optional[b def from_client_network_config( network: Union[Unset, SandboxNetworkConfig], -) -> Optional[SandboxNetworkOpts]: +) -> Optional[SandboxNetworkInfo]: if isinstance(network, Unset): return None - result: SandboxNetworkOpts = {} + result: SandboxNetworkInfo = {} if not isinstance(network.allow_out, Unset): - result["allow_out"] = [ - cast(SandboxNetworkRule, item.to_dict()) - if isinstance(item, ClientSandboxNetworkRule) - else item - for item in network.allow_out - ] + result["allow_out"] = list(network.allow_out) if not isinstance(network.deny_out, Unset): result["deny_out"] = list(network.deny_out) if not isinstance(network.allow_public_traffic, Unset): @@ -183,6 +304,15 @@ def from_client_network_config( return result +def from_client_firewall( + firewall: Union[Unset, ClientSandboxFirewall], +) -> Optional[SandboxFirewall]: + if isinstance(firewall, Unset): + return None + + return cast(SandboxFirewall, firewall.to_dict()) + + def from_client_lifecycle( lifecycle: Union[Unset, ClientSandboxLifecycle], ) -> Optional[SandboxInfoLifecycle]: @@ -227,8 +357,10 @@ class SandboxInfo: """Envd access token.""" allow_internet_access: Optional[bool] = None """Whether internet access was explicitly enabled or disabled for the sandbox.""" - network: Optional[SandboxNetworkOpts] = None + network: Optional[SandboxNetworkInfo] = None """Sandbox network configuration.""" + firewall: Optional[SandboxFirewall] = None + """Per-host firewall rules registered for the sandbox.""" lifecycle: Optional[SandboxInfoLifecycle] = None """Sandbox lifecycle configuration.""" volume_mounts: List[Dict[str, str]] = field(default_factory=list) @@ -241,7 +373,8 @@ def _from_sandbox_data( envd_access_token: Optional[str] = None, sandbox_domain: Optional[str] = None, allow_internet_access: Optional[bool] = None, - network: Optional[SandboxNetworkOpts] = None, + network: Optional[SandboxNetworkInfo] = None, + firewall: Optional[SandboxFirewall] = None, lifecycle: Optional[SandboxInfoLifecycle] = None, ): return cls( @@ -267,6 +400,7 @@ def _from_sandbox_data( _envd_access_token=envd_access_token, allow_internet_access=allow_internet_access, network=network, + firewall=firewall, lifecycle=lifecycle, ) @@ -294,6 +428,7 @@ def _from_sandbox_detail(cls, sandbox_detail: SandboxDetail): else None ), network=from_client_network_config(sandbox_detail.network), + firewall=from_client_firewall(sandbox_detail.firewall), lifecycle=from_client_lifecycle(sandbox_detail.lifecycle), ) diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 3dd7044ea7..0160c82f93 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -21,6 +21,7 @@ from e2b.sandbox.main import SandboxOpts from e2b.sandbox.sandbox_api import ( McpServer, + SandboxFirewall, SandboxLifecycle, SandboxMetrics, SandboxNetworkOpts, @@ -177,6 +178,7 @@ async def create( allow_internet_access: bool = True, mcp: Optional[McpServer] = None, network: Optional[SandboxNetworkOpts] = None, + firewall: Optional[SandboxFirewall] = None, lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[SandboxAsyncVolumeMount] = None, **opts: Unpack[ApiParams], @@ -193,7 +195,8 @@ async def create( :param secure: Envd is secured with access token and cannot be used without it, defaults to `True`. :param allow_internet_access: Allow sandbox to access the internet, defaults to `True`. If set to `False`, it works the same as setting network `deny_out` to `[0.0.0.0/0]`. :param mcp: MCP server to enable in the sandbox - :param network: Sandbox network configuration + :param network: Sandbox network configuration. ``allow_out``/``deny_out`` may also be a callable receiving a :class:`SandboxNetworkSelectorContext` (``ctx.firewall_hosts``, ``ctx.all_hosts``) and returning a list of strings. + :param firewall: Per-host firewall rules applied to outbound requests. Hosts registered here are exposed to the network ``allow_out``/``deny_out`` callables via ``firewall_hosts`` but are not allowed by default — they must also appear in ``network.allow_out``. :param lifecycle: Sandbox lifecycle configuration — ``on_timeout``: ``"kill"`` (default) or ``"pause"``; ``auto_resume``: ``False`` (default) or ``True`` (only when ``on_timeout="pause"``). Example: ``{"on_timeout": "pause", "auto_resume": True}`` :param volume_mounts: Dictionary mapping mount paths to AsyncVolume instances or volume names @@ -226,6 +229,7 @@ async def create( allow_internet_access=allow_internet_access, mcp=mcp, network=network, + firewall=firewall, lifecycle=lifecycle, volume_mounts=transformed_mounts, **opts, @@ -892,6 +896,7 @@ async def _create( secure: bool, mcp: Optional[McpServer] = None, network: Optional[SandboxNetworkOpts] = None, + firewall: Optional[SandboxFirewall] = None, lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[list] = None, **opts: Unpack[ApiParams], @@ -916,6 +921,7 @@ async def _create( allow_internet_access=allow_internet_access, mcp=mcp, network=network, + firewall=firewall, lifecycle=lifecycle, volume_mounts=volume_mounts, **opts, diff --git a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py index 8946b038fa..dfadf280f8 100644 --- a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py @@ -37,7 +37,10 @@ ) from e2b.sandbox.main import SandboxBase from e2b.sandbox.sandbox_api import ( + SandboxFirewall, SandboxLifecycle, + build_firewall_config, + build_network_config, get_auto_resume_enabled, McpServer, SandboxInfo, @@ -171,6 +174,7 @@ async def _create_sandbox( secure: bool, mcp: Optional[McpServer] = None, network: Optional[SandboxNetworkOpts] = None, + firewall: Optional[SandboxFirewall] = None, lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[List[SandboxVolumeMountAPI]] = None, **opts: Unpack[ApiParams], @@ -181,6 +185,8 @@ async def _create_sandbox( lifecycle["on_timeout"] == "pause" if lifecycle is not None else auto_pause ) auto_resume_enabled = get_auto_resume_enabled(lifecycle) + network_body = build_network_config(network, firewall) + firewall_body = build_firewall_config(firewall) body = NewSandbox( template_id=template, auto_pause=(should_auto_pause if should_auto_pause is not None else UNSET), @@ -190,7 +196,8 @@ async def _create_sandbox( mcp=cast(Any, mcp) or UNSET, secure=secure, allow_internet_access=allow_internet_access, - network=SandboxNetworkConfig(**network) if network else UNSET, + network=SandboxNetworkConfig(**network_body) if network_body else UNSET, + firewall=firewall_body if firewall_body is not None else UNSET, volume_mounts=volume_mounts if volume_mounts else UNSET, ) if auto_resume_enabled is not None: diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index 43f3a858c5..3fc70995fb 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -21,6 +21,7 @@ from e2b.sandbox.main import SandboxOpts from e2b.sandbox.sandbox_api import ( McpServer, + SandboxFirewall, SandboxLifecycle, SandboxMetrics, SandboxNetworkOpts, @@ -175,6 +176,7 @@ def create( allow_internet_access: bool = True, mcp: Optional[McpServer] = None, network: Optional[SandboxNetworkOpts] = None, + firewall: Optional[SandboxFirewall] = None, lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[SandboxVolumeMount] = None, **opts: Unpack[ApiParams], @@ -191,7 +193,8 @@ def create( :param secure: Envd is secured with access token and cannot be used without it, defaults to `True`. :param allow_internet_access: Allow sandbox to access the internet, defaults to `True`. If set to `False`, it works the same as setting network `deny_out` to `[0.0.0.0/0]`. :param mcp: MCP server to enable in the sandbox - :param network: Sandbox network configuration + :param network: Sandbox network configuration. ``allow_out``/``deny_out`` may also be a callable receiving a :class:`SandboxNetworkSelectorContext` (``ctx.firewall_hosts``, ``ctx.all_hosts``) and returning a list of strings. + :param firewall: Per-host firewall rules applied to outbound requests. Hosts registered here are exposed to the network ``allow_out``/``deny_out`` callables via ``firewall_hosts`` but are not allowed by default — they must also appear in ``network.allow_out``. :param lifecycle: Sandbox lifecycle configuration — ``on_timeout``: ``"kill"`` (default) or ``"pause"``; ``auto_resume``: ``False`` (default) or ``True`` (only when ``on_timeout="pause"``). Example: ``{"on_timeout": "pause", "auto_resume": True}`` :param volume_mounts: Dictionary mapping mount paths to Volume instances or volume names @@ -224,6 +227,7 @@ def create( allow_internet_access=allow_internet_access, mcp=mcp, network=network, + firewall=firewall, lifecycle=lifecycle, volume_mounts=transformed_mounts, **opts, @@ -887,6 +891,7 @@ def _create( allow_internet_access: bool, mcp: Optional[McpServer] = None, network: Optional[SandboxNetworkOpts] = None, + firewall: Optional[SandboxFirewall] = None, lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[list] = None, **opts: Unpack[ApiParams], @@ -911,6 +916,7 @@ def _create( allow_internet_access=allow_internet_access, mcp=mcp, network=network, + firewall=firewall, lifecycle=lifecycle, volume_mounts=volume_mounts, **opts, diff --git a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py index 4cad1247dc..f5532229ba 100644 --- a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py @@ -36,7 +36,10 @@ ) from e2b.sandbox.main import SandboxBase from e2b.sandbox.sandbox_api import ( + SandboxFirewall, SandboxLifecycle, + build_firewall_config, + build_network_config, get_auto_resume_enabled, McpServer, SandboxInfo, @@ -170,6 +173,7 @@ def _create_sandbox( secure: bool, mcp: Optional[McpServer] = None, network: Optional[SandboxNetworkOpts] = None, + firewall: Optional[SandboxFirewall] = None, lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[List[SandboxVolumeMountAPI]] = None, **opts: Unpack[ApiParams], @@ -180,6 +184,8 @@ def _create_sandbox( lifecycle["on_timeout"] == "pause" if lifecycle is not None else auto_pause ) auto_resume_enabled = get_auto_resume_enabled(lifecycle) + network_body = build_network_config(network, firewall) + firewall_body = build_firewall_config(firewall) body = NewSandbox( template_id=template, auto_pause=(should_auto_pause if should_auto_pause is not None else UNSET), @@ -189,7 +195,8 @@ def _create_sandbox( mcp=cast(Any, mcp) or UNSET, secure=secure, allow_internet_access=allow_internet_access, - network=SandboxNetworkConfig(**network) if network else UNSET, + network=SandboxNetworkConfig(**network_body) if network_body else UNSET, + firewall=firewall_body if firewall_body is not None else UNSET, volume_mounts=volume_mounts if volume_mounts else UNSET, ) if auto_resume_enabled is not None: diff --git a/packages/python-sdk/tests/async/sandbox_async/test_network.py b/packages/python-sdk/tests/async/sandbox_async/test_network.py index 42125d5d54..3ec0be6fbe 100644 --- a/packages/python-sdk/tests/async/sandbox_async/test_network.py +++ b/packages/python-sdk/tests/async/sandbox_async/test_network.py @@ -2,7 +2,7 @@ import pytest -from e2b import ALL_TRAFFIC, SandboxNetworkOpts +from e2b import SandboxNetworkOpts from e2b.sandbox.commands.command_handle import CommandExitException @@ -10,7 +10,9 @@ async def test_allow_specific_ip_with_deny_all(async_sandbox_factory): """Test that sandbox with denyOut all and allowOut creates a whitelist.""" async_sandbox = await async_sandbox_factory( - network=SandboxNetworkOpts(deny_out=[ALL_TRAFFIC], allow_out=["1.1.1.1"]) + network=SandboxNetworkOpts( + deny_out=lambda ctx: ctx.all_hosts, allow_out=["1.1.1.1"] + ) ) # Test that allowed IP works @@ -52,9 +54,9 @@ async def test_deny_specific_ip(async_sandbox_factory): @pytest.mark.skip_debug() async def test_deny_all_traffic(async_sandbox_factory): - """Test that sandbox can deny all traffic using all_traffic helper.""" + """Test that sandbox can deny all traffic using the all_hosts selector.""" async_sandbox = await async_sandbox_factory( - network=SandboxNetworkOpts(deny_out=[ALL_TRAFFIC]), timeout=30 + network=SandboxNetworkOpts(deny_out=lambda ctx: ctx.all_hosts), timeout=30 ) # Test that all traffic is denied @@ -76,7 +78,7 @@ async def test_allow_takes_precedence_over_deny(async_sandbox_factory): """Test that allowOut takes precedence over denyOut.""" async_sandbox = await async_sandbox_factory( network=SandboxNetworkOpts( - deny_out=[ALL_TRAFFIC], allow_out=["1.1.1.1", "8.8.8.8"] + deny_out=lambda ctx: ctx.all_hosts, allow_out=["1.1.1.1", "8.8.8.8"] ) ) @@ -162,20 +164,20 @@ async def test_allow_public_traffic_true(async_sandbox_factory): @pytest.mark.skip_debug() -async def test_allow_out_transform_injects_headers(async_sandbox_factory): - """Test that an allow_out rule with a transform injects headers into outbound requests.""" +async def test_firewall_transform_injects_headers(async_sandbox_factory): + """Test that a firewall rule with a transform injects headers into outbound requests.""" injected_header = "X-E2B-Test-Token" injected_value = "e2b-transform-value-123" network: SandboxNetworkOpts = { - "allow_out": [ - { - "host": "httpbin.org", - "transform": [{"headers": {injected_header: injected_value}}], - }, + "allow_out": lambda ctx: ctx.firewall_hosts, + } + firewall = { + "httpbin.org": [ + {"transform": {"headers": {injected_header: injected_value}}}, ], } - async_sandbox = await async_sandbox_factory(network=network) + async_sandbox = await async_sandbox_factory(network=network, firewall=firewall) result = await async_sandbox.commands.run( "curl -sS --max-time 10 https://httpbin.org/headers" diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_network.py b/packages/python-sdk/tests/sync/sandbox_sync/test_network.py index a8252225de..26280134ca 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_network.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_network.py @@ -2,7 +2,7 @@ import pytest -from e2b import ALL_TRAFFIC, SandboxNetworkOpts +from e2b import SandboxNetworkOpts from e2b.sandbox.commands.command_handle import CommandExitException @@ -10,7 +10,9 @@ def test_allow_specific_ip_with_deny_all(sandbox_factory): """Test that sandbox with denyOut all and allowOut creates a whitelist.""" sandbox = sandbox_factory( - network=SandboxNetworkOpts(deny_out=[ALL_TRAFFIC], allow_out=["1.1.1.1"]) + network=SandboxNetworkOpts( + deny_out=lambda ctx: ctx.all_hosts, allow_out=["1.1.1.1"] + ) ) # Test that allowed IP works @@ -50,9 +52,9 @@ def test_deny_specific_ip(sandbox_factory): @pytest.mark.skip_debug() def test_deny_all_traffic(sandbox_factory): - """Test that sandbox can deny all traffic using all_traffic helper.""" + """Test that sandbox can deny all traffic using the all_hosts selector.""" sandbox = sandbox_factory( - network=SandboxNetworkOpts(deny_out=[ALL_TRAFFIC]), timeout=30 + network=SandboxNetworkOpts(deny_out=lambda ctx: ctx.all_hosts), timeout=30 ) # Test that all traffic is denied @@ -74,7 +76,7 @@ def test_allow_takes_precedence_over_deny(sandbox_factory): """Test that allowOut takes precedence over denyOut.""" sandbox = sandbox_factory( network=SandboxNetworkOpts( - deny_out=[ALL_TRAFFIC], allow_out=["1.1.1.1", "8.8.8.8"] + deny_out=lambda ctx: ctx.all_hosts, allow_out=["1.1.1.1", "8.8.8.8"] ) ) @@ -160,20 +162,20 @@ def test_allow_public_traffic_true(sandbox_factory): @pytest.mark.skip_debug() -def test_allow_out_transform_injects_headers(sandbox_factory): - """Test that an allow_out rule with a transform injects headers into outbound requests.""" +def test_firewall_transform_injects_headers(sandbox_factory): + """Test that a firewall rule with a transform injects headers into outbound requests.""" injected_header = "X-E2B-Test-Token" injected_value = "e2b-transform-value-123" network: SandboxNetworkOpts = { - "allow_out": [ - { - "host": "httpbin.org", - "transform": [{"headers": {injected_header: injected_value}}], - }, + "allow_out": lambda ctx: ctx.firewall_hosts, + } + firewall = { + "httpbin.org": [ + {"transform": {"headers": {injected_header: injected_value}}}, ], } - sandbox = sandbox_factory(network=network) + sandbox = sandbox_factory(network=network, firewall=firewall) result = sandbox.commands.run("curl -sS --max-time 10 https://httpbin.org/headers") assert result.exit_code == 0 diff --git a/spec/openapi.yml b/spec/openapi.yml index 8508fb2c8e..7d3664164a 100644 --- a/spec/openapi.yml +++ b/spec/openapi.yml @@ -262,9 +262,9 @@ components: additionalProperties: {} nullable: true - SandboxNetworkRuleTransform: + SandboxFirewallRuleTransform: type: object - description: Transform applied to egress requests matching a network rule. + description: Transform applied to egress requests matching a firewall rule. properties: headers: type: object @@ -272,20 +272,20 @@ components: additionalProperties: type: string - SandboxNetworkRule: + SandboxFirewallRule: type: object - description: Structured egress rule matching a host, optionally transforming the request before it leaves the sandbox. - required: - - host + description: Firewall rule applied to outbound requests matching the host it is registered under. properties: - host: - type: string - description: Host, CIDR block, or IP address the rule applies to. transform: - type: array - description: Ordered list of transforms to apply to requests matching this rule. - items: - $ref: '#/components/schemas/SandboxNetworkRuleTransform' + $ref: '#/components/schemas/SandboxFirewallRuleTransform' + + SandboxFirewall: + type: object + description: Map of host to ordered list of firewall rules applied to outbound requests for that host. Registering a host here does not allow egress on its own; the host must also appear in network.allowOut. + additionalProperties: + type: array + items: + $ref: '#/components/schemas/SandboxFirewallRule' SandboxNetworkConfig: type: object @@ -296,14 +296,12 @@ components: description: Specify if the sandbox URLs should be accessible only with authentication. allowOut: type: array - description: List of allowed egress entries. Each entry is either a CIDR block/IP/host string, or a structured rule with optional per-host transforms. Allowed entries always take precedence over denied ones. + description: List of allowed CIDR blocks, IP addresses, or hostnames for egress traffic. Allowed entries always take precedence over denied ones. items: - oneOf: - - type: string - - $ref: '#/components/schemas/SandboxNetworkRule' + type: string denyOut: type: array - description: List of denied CIDR blocks or IP addresses for egress traffic + description: List of denied CIDR blocks, IP addresses, or hostnames for egress traffic. items: type: string maskRequestHost: @@ -558,6 +556,8 @@ components: $ref: '#/components/schemas/SandboxState' network: $ref: '#/components/schemas/SandboxNetworkConfig' + firewall: + $ref: '#/components/schemas/SandboxFirewall' lifecycle: $ref: '#/components/schemas/SandboxLifecycle' volumeMounts: @@ -653,6 +653,8 @@ components: to 0.0.0.0/0 in the network config. network: $ref: '#/components/schemas/SandboxNetworkConfig' + firewall: + $ref: '#/components/schemas/SandboxFirewall' metadata: $ref: '#/components/schemas/SandboxMetadata' envVars: From 5a5f88524d47794138a076b9ca918914af849370 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:04:14 +0200 Subject: [PATCH 11/23] refactor(sdk): nest firewall rules under network.rules Move per-host transform rules from a top-level `firewall` field to `network.rules`. The selector context now exposes `{ allTraffic, rules }` where `allTraffic` is `'0.0.0.0/0'` and `rules` is a Map (Mapping in Python) view of `network.rules`. SandboxFirewall* schemas renamed to SandboxNetworkRule / SandboxNetworkTransform. Co-Authored-By: Claude Opus 4.7 --- packages/js-sdk/src/api/schema.gen.ts | 105 +- packages/js-sdk/src/index.ts | 6 +- packages/js-sdk/src/sandbox/sandboxApi.ts | 119 +- packages/js-sdk/tests/sandbox/network.test.ts | 30 +- packages/python-sdk/e2b/__init__.py | 12 +- .../put_sandboxes_sandbox_id_network.py | 193 +++ .../e2b/api/client/models/__init__.py | 22 +- .../e2b/api/client/models/new_sandbox.py | 20 - .../python-sdk/e2b/api/client/models/node.py | 5 +- .../e2b/api/client/models/node_detail.py | 5 +- .../e2b/api/client/models/node_status.py | 1 + .../api/client/models/node_status_change.py | 5 +- .../put_sandboxes_sandbox_id_network_body.py | 112 ++ ...andboxes_sandbox_id_network_body_rules.py} | 29 +- .../e2b/api/client/models/sandbox_detail.py | 20 - .../e2b/api/client/models/sandbox_metric.py | 8 + .../client/models/sandbox_network_config.py | 34 +- .../models/sandbox_network_config_rules.py | 72 + ...rewall_rule.py => sandbox_network_rule.py} | 28 +- ...nsform.py => sandbox_network_transform.py} | 31 +- ...y => sandbox_network_transform_headers.py} | 15 +- .../python-sdk/e2b/sandbox/sandbox_api.py | 161 ++- packages/python-sdk/e2b/sandbox_async/main.py | 8 +- .../e2b/sandbox_async/sandbox_api.py | 7 +- packages/python-sdk/e2b/sandbox_sync/main.py | 8 +- .../e2b/sandbox_sync/sandbox_api.py | 7 +- .../tests/async/sandbox_async/test_network.py | 22 +- .../tests/sync/sandbox_sync/test_network.py | 22 +- spec/openapi.yml | 1246 +++++++++-------- 29 files changed, 1416 insertions(+), 937 deletions(-) create mode 100644 packages/python-sdk/e2b/api/client/api/sandboxes/put_sandboxes_sandbox_id_network.py create mode 100644 packages/python-sdk/e2b/api/client/models/put_sandboxes_sandbox_id_network_body.py rename packages/python-sdk/e2b/api/client/models/{sandbox_firewall.py => put_sandboxes_sandbox_id_network_body_rules.py} (65%) create mode 100644 packages/python-sdk/e2b/api/client/models/sandbox_network_config_rules.py rename packages/python-sdk/e2b/api/client/models/{sandbox_firewall_rule.py => sandbox_network_rule.py} (65%) rename packages/python-sdk/e2b/api/client/models/{sandbox_firewall_rule_transform.py => sandbox_network_transform.py} (60%) rename packages/python-sdk/e2b/api/client/models/{sandbox_firewall_rule_transform_headers.py => sandbox_network_transform_headers.py} (68%) diff --git a/packages/js-sdk/src/api/schema.gen.ts b/packages/js-sdk/src/api/schema.gen.ts index 67c276066c..d9dbeff861 100644 --- a/packages/js-sdk/src/api/schema.gen.ts +++ b/packages/js-sdk/src/api/schema.gen.ts @@ -287,6 +287,61 @@ export interface paths { patch?: never; trace?: never; }; + "/sandboxes/{sandboxID}/network": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** @description Update the network configuration for a running sandbox. Replaces the current egress rules with the provided configuration. Omitting both fields clears all egress rules. */ + put: { + parameters: { + query?: never; + header?: never; + path: { + sandboxID: components["parameters"]["sandboxID"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Allow sandbox to access the internet. When set to false, it behaves the same as specifying denyOut to 0.0.0.0/0 in the network config. */ + allow_internet_access?: boolean; + /** @description List of allowed destinations for egress traffic. Each entry can be a CIDR block (e.g. "8.8.8.8/32"), a bare IP address (e.g. "8.8.8.8"), or a domain name (e.g. "example.com", "*.example.com"). Allowed entries always take precedence over denied entries. */ + allowOut?: string[]; + /** @description List of denied CIDR blocks or IP addresses for egress traffic. Domain names are not supported for deny rules. */ + denyOut?: string[]; + /** @description Per-domain transform rules. Replaces all existing rules when provided. */ + rules?: { + [key: string]: components["schemas"]["SandboxNetworkRule"][]; + }; + }; + }; + }; + responses: { + /** @description Successfully updated the sandbox network configuration */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["401"]; + 404: components["responses"]["404"]; + 409: components["responses"]["409"]; + 500: components["responses"]["500"]; + }; + }; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/sandboxes/{sandboxID}/pause": { parameters: { query?: never; @@ -1966,7 +2021,6 @@ export interface components { autoPause?: boolean; autoResume?: components["schemas"]["SandboxAutoResumeConfig"]; envVars?: components["schemas"]["EnvVars"]; - firewall?: components["schemas"]["SandboxFirewall"]; mcp?: components["schemas"]["Mcp"]; metadata?: components["schemas"]["SandboxMetadata"]; network?: components["schemas"]["SandboxNetworkConfig"]; @@ -2093,10 +2147,13 @@ export interface components { memoryUsedBytes: number; }; /** - * @description Status of the node + * @description Status of the node. + * - draining: the node is bound to be shut down. It will not accept new sandboxes and will stop once all existing sandboxes are done. + * - standby: the node is not actively used, but it can return to ready and continue serving traffic. + * * @enum {string} */ - NodeStatus: "ready" | "draining" | "connecting" | "unhealthy"; + NodeStatus: "ready" | "draining" | "connecting" | "unhealthy" | "standby"; NodeStatusChange: { /** * Format: uuid @@ -2169,7 +2226,6 @@ export interface components { /** @description Access token used for envd communication */ envdAccessToken?: string; envdVersion: components["schemas"]["EnvdVersion"]; - firewall?: components["schemas"]["SandboxFirewall"]; lifecycle?: components["schemas"]["SandboxLifecycle"]; memoryMB: components["schemas"]["MemoryMB"]; metadata?: components["schemas"]["SandboxMetadata"]; @@ -2191,21 +2247,6 @@ export interface components { [key: string]: components["schemas"]["SandboxMetric"]; }; }; - /** @description Map of host to ordered list of firewall rules applied to outbound requests for that host. Registering a host here does not allow egress on its own; the host must also appear in network.allowOut. */ - SandboxFirewall: { - [key: string]: components["schemas"]["SandboxFirewallRule"][]; - }; - /** @description Firewall rule applied to outbound requests matching the host it is registered under. */ - SandboxFirewallRule: { - transform?: components["schemas"]["SandboxFirewallRuleTransform"]; - }; - /** @description Transform applied to egress requests matching a firewall rule. */ - SandboxFirewallRuleTransform: { - /** @description Headers to inject into the outbound request. Values override any headers already present. */ - headers?: { - [key: string]: string; - }; - }; /** @description Sandbox lifecycle policy returned by sandbox info. */ SandboxLifecycle: { /** @description Whether the sandbox can auto-resume. */ @@ -2273,6 +2314,11 @@ export interface components { * @description Disk used in bytes */ diskUsed: number; + /** + * Format: int64 + * @description Cached memory (page cache) in bytes + */ + memCache: number; /** * Format: int64 * @description Total memory in bytes @@ -2296,17 +2342,34 @@ export interface components { timestampUnix: number; }; SandboxNetworkConfig: { - /** @description List of allowed CIDR blocks, IP addresses, or hostnames for egress traffic. Allowed entries always take precedence over denied ones. */ + /** @description List of allowed destinations for egress traffic. Each entry can be a CIDR block (e.g. "8.8.8.8/32"), a bare IP address (e.g. "8.8.8.8"), or a domain name (e.g. "example.com", "*.example.com"). Allowed entries always take precedence over denied entries. */ allowOut?: string[]; /** * @description Specify if the sandbox URLs should be accessible only with authentication. * @default true */ allowPublicTraffic?: boolean; - /** @description List of denied CIDR blocks, IP addresses, or hostnames for egress traffic. */ + /** @description List of denied CIDR blocks or IP addresses for egress traffic. Domain names are not supported for deny rules. */ denyOut?: string[]; /** @description Specify host mask which will be used for all sandbox requests */ maskRequestHost?: string; + /** @description Per-domain transform rules applied to matching egress HTTP/HTTPS requests. Keys are domains (e.g. "api.example.com", "example.com"). A domain listed here is not automatically allowed - use allowOut to permit the traffic. + * */ + rules?: { + [key: string]: components["schemas"]["SandboxNetworkRule"][]; + }; + }; + /** @description Transform rule applied to egress requests matching a domain pattern. */ + SandboxNetworkRule: { + transform?: components["schemas"]["SandboxNetworkTransform"]; + }; + /** @description Transformations applied to matching egress requests before forwarding. */ + SandboxNetworkTransform: { + /** @description HTTP headers to inject or override in matching requests. An existing header with the same name is replaced. Values are plain strings; secret resolution happens client-side before sending to the API. + * */ + headers?: { + [key: string]: string; + }; }; /** * @description Action taken when the sandbox times out. diff --git a/packages/js-sdk/src/index.ts b/packages/js-sdk/src/index.ts index 3ecab29e94..9713a54bf7 100644 --- a/packages/js-sdk/src/index.ts +++ b/packages/js-sdk/src/index.ts @@ -61,9 +61,9 @@ export type { SandboxNetworkInfo, SandboxNetworkSelector, SandboxNetworkSelectorContext, - SandboxFirewall, - SandboxFirewallRule, - SandboxFirewallRuleTransform, + SandboxNetworkRule, + SandboxNetworkRules, + SandboxNetworkTransform, SandboxLifecycle, SandboxInfoLifecycle, SnapshotInfo, diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index c75f7f85b7..786d7c7fac 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -35,9 +35,9 @@ export type GitHubMcpServer = { } /** - * Transform applied to outbound requests matching a {@link SandboxFirewallRule}. + * Transform applied to egress requests matching a {@link SandboxNetworkRule}. */ -export type SandboxFirewallRuleTransform = { +export type SandboxNetworkTransform = { /** * Headers to inject into the outbound request. Values override any headers * already present on the request. @@ -46,37 +46,35 @@ export type SandboxFirewallRuleTransform = { } /** - * Firewall rule applied to outbound requests matching the host it is - * registered under in {@link SandboxOpts.firewall}. + * Per-domain rule applied to egress requests. */ -export type SandboxFirewallRule = { +export type SandboxNetworkRule = { /** Transform applied to requests matching this rule. */ - transform?: SandboxFirewallRuleTransform + transform?: SandboxNetworkTransform } /** - * Map of host (or CIDR / IP) to ordered list of firewall rules applied to - * outbound requests for that host. Registering a host here does not allow - * egress on its own — the host must also appear in - * {@link SandboxNetworkOpts.allowOut}. + * Map of host (or CIDR / IP) to ordered list of rules applied to outbound + * requests for that host. Registering a host here does not allow egress on + * its own — the host must also appear in {@link SandboxNetworkOpts.allowOut}. */ -export type SandboxFirewall = Record +export type SandboxNetworkRules = Record /** * Context passed to {@link SandboxNetworkOpts.allowOut} and * {@link SandboxNetworkOpts.denyOut} when they are defined as functions. */ export type SandboxNetworkSelectorContext = { - /** Hosts registered in {@link SandboxOpts.firewall}. */ - firewallHosts: string[] - /** All traffic — equivalent to `['0.0.0.0/0']`. */ - allHosts: string[] + /** All traffic sentinel — equivalent to `'0.0.0.0/0'`. */ + allTraffic: string + /** Rules registered in {@link SandboxNetworkOpts.rules}. */ + rules: Map } /** * Egress rule list, either a static array of CIDR blocks / IP addresses / - * hostnames, or a callback that receives the resolved firewall hosts and - * returns the same. + * hostnames, or a callback that receives `{ allTraffic, rules }` and returns + * the same. */ export type SandboxNetworkSelector = | string[] @@ -88,14 +86,14 @@ export type SandboxNetworkOpts = { * If `allowOut` is not specified, all outbound traffic is allowed. * * Accepts either a static array of CIDR blocks, IP addresses, or hostnames, - * or a callback that receives `{ firewallHosts, allHosts }` and returns the - * same. `firewallHosts` is the list of hosts registered in - * {@link SandboxOpts.firewall}; `allHosts` is `['0.0.0.0/0']`. + * or a callback that receives `{ allTraffic, rules }` and returns the same. + * `allTraffic` is `'0.0.0.0/0'`; `rules` is a `Map` view of + * {@link SandboxNetworkOpts.rules}. * * Examples: * - Static list: `["1.1.1.1", "8.8.8.0/24"]` - * - Allow only firewall-registered hosts: - * `({ firewallHosts }) => firewallHosts` + * - Allow only rule-registered hosts: + * `({ rules }) => [...rules.keys()]` */ allowOut?: SandboxNetworkSelector @@ -106,10 +104,35 @@ export type SandboxNetworkOpts = { * * Examples: * - Static list: `["1.1.1.1", "8.8.8.0/24"]` - * - Block all egress: `({ allHosts }) => allHosts` + * - Block all egress: `({ allTraffic }) => [allTraffic]` */ denyOut?: SandboxNetworkSelector + /** + * Per-domain transform rules applied to matching egress HTTP/HTTPS + * requests. Keys are domains (e.g. `"api.example.com"`); values are + * ordered lists of rules. + * + * Registering a host here does not allow egress on its own — the host must + * also appear in {@link allowOut}. Hosts registered here are exposed to the + * `allowOut`/`denyOut` callbacks via `rules`. + * + * @example + * ```ts + * await Sandbox.create({ + * network: { + * allowOut: ({ rules }) => [...rules.keys()], + * rules: { + * 'api.openai.com': [ + * { transform: { headers: { Authorization: `Bearer ${token}` } } }, + * ], + * }, + * }, + * }) + * ``` + */ + rules?: SandboxNetworkRules + /** * Specify if the sandbox URLs should be accessible only with authentication. * @default true @@ -132,6 +155,7 @@ export type SandboxNetworkOpts = { export type SandboxNetworkInfo = { allowOut?: string[] denyOut?: string[] + rules?: SandboxNetworkRules allowPublicTraffic?: boolean maskRequestHost?: string } @@ -235,29 +259,6 @@ export interface SandboxOpts extends ConnectionOpts { */ network?: SandboxNetworkOpts - /** - * Per-host firewall rules applied to outbound requests. The keys are hosts - * (or CIDR blocks / IP addresses); the values are ordered lists of rules - * (currently a `transform` describing request modifications). - * - * Registering a host here does not allow egress on its own — the host must - * also appear in {@link SandboxNetworkOpts.allowOut}. Hosts registered here - * are exposed to the `allowOut`/`denyOut` callbacks via `firewallHosts`. - * - * @example - * ```ts - * await Sandbox.create({ - * network: { allowOut: ({ firewallHosts }) => firewallHosts }, - * firewall: { - * 'api.openai.com': [ - * { transform: { headers: { Authorization: `Bearer ${token}` } } }, - * ], - * }, - * }) - * ``` - */ - firewall?: SandboxFirewall - /** * Volume mounts for the sandbox. * @@ -445,11 +446,6 @@ export interface SandboxInfo { */ network?: SandboxNetworkInfo - /** - * Per-host firewall rules registered for the sandbox. - */ - firewall?: SandboxFirewall - /** * Sandbox lifecycle configuration. */ @@ -501,38 +497,36 @@ export interface SandboxMetrics { diskTotal: number } -const ALL_TRAFFIC_HOSTS: readonly string[] = [ALL_TRAFFIC] - function resolveNetworkSelector( selector: SandboxNetworkSelector | undefined, - firewallHosts: string[] + rules: Map ): string[] | undefined { if (selector === undefined) { return undefined } if (typeof selector === 'function') { - return selector({ firewallHosts, allHosts: [...ALL_TRAFFIC_HOSTS] }) + return selector({ allTraffic: ALL_TRAFFIC, rules }) } return selector } function buildNetworkBody( - network: SandboxNetworkOpts | undefined, - firewall: SandboxFirewall | undefined + network: SandboxNetworkOpts | undefined ): components['schemas']['SandboxNetworkConfig'] | undefined { if (!network) { return undefined } - const firewallHosts = firewall ? Object.keys(firewall) : [] - const allowOut = resolveNetworkSelector(network.allowOut, firewallHosts) - const denyOut = resolveNetworkSelector(network.denyOut, firewallHosts) + const rules = new Map(Object.entries(network.rules ?? {})) + const allowOut = resolveNetworkSelector(network.allowOut, rules) + const denyOut = resolveNetworkSelector(network.denyOut, rules) return { ...(allowOut !== undefined ? { allowOut } : {}), ...(denyOut !== undefined ? { denyOut } : {}), + ...(network.rules !== undefined ? { rules: network.rules } : {}), ...(network.allowPublicTraffic !== undefined ? { allowPublicTraffic: network.allowPublicTraffic } : {}), @@ -754,11 +748,11 @@ export class SandboxApi { ? { allowOut: res.data.network.allowOut, denyOut: res.data.network.denyOut, + rules: res.data.network.rules ?? undefined, allowPublicTraffic: res.data.network.allowPublicTraffic, maskRequestHost: res.data.network.maskRequestHost, } : undefined, - firewall: res.data.firewall ?? undefined, lifecycle: res.data.lifecycle ? { onTimeout: res.data.lifecycle.onTimeout, @@ -933,8 +927,7 @@ export class SandboxApi { timeout: timeoutToSeconds(timeoutMs), secure: opts?.secure ?? true, allow_internet_access: opts?.allowInternetAccess ?? true, - network: buildNetworkBody(opts?.network, opts?.firewall), - ...(opts?.firewall ? { firewall: opts.firewall } : {}), + network: buildNetworkBody(opts?.network), ...(autoPause !== undefined ? { autoPause } : {}), ...(autoResumeEnabled !== undefined ? { autoResume: { enabled: autoResumeEnabled } } diff --git a/packages/js-sdk/tests/sandbox/network.test.ts b/packages/js-sdk/tests/sandbox/network.test.ts index b4c9e91bfd..31b4806d04 100644 --- a/packages/js-sdk/tests/sandbox/network.test.ts +++ b/packages/js-sdk/tests/sandbox/network.test.ts @@ -7,7 +7,7 @@ describe('allow only 1.1.1.1', () => { sandboxTest.scoped({ sandboxOpts: { network: { - denyOut: ({ allHosts }) => allHosts, + denyOut: ({ allTraffic }) => [allTraffic], allowOut: ['1.1.1.1'], }, }, @@ -62,17 +62,17 @@ describe('deny specific IP address', () => { ) }) -describe('deny all traffic using allHosts selector', () => { +describe('deny all traffic using allTraffic selector', () => { sandboxTest.scoped({ sandboxOpts: { network: { - denyOut: ({ allHosts }) => allHosts, + denyOut: ({ allTraffic }) => [allTraffic], }, }, }) sandboxTest.skipIf(isDebug)( - 'deny all traffic using allHosts selector', + 'deny all traffic using allTraffic selector', async ({ sandbox }) => { // Test that all traffic is denied await expect( @@ -94,7 +94,7 @@ describe('allow takes precedence over deny', () => { sandboxTest.scoped({ sandboxOpts: { network: { - denyOut: ({ allHosts }) => allHosts, + denyOut: ({ allTraffic }) => [allTraffic], allowOut: ['1.1.1.1', '8.8.8.8'], }, }, @@ -200,18 +200,18 @@ describe('firewall transform injects headers', () => { sandboxTest.scoped({ sandboxOpts: { network: { - allowOut: ({ firewallHosts }) => firewallHosts, - }, - firewall: { - 'httpbin.org': [ - { - transform: { - headers: { - [injectedHeader]: injectedValue, + allowOut: ({ rules }) => [...rules.keys()], + rules: { + 'httpbin.org': [ + { + transform: { + headers: { + [injectedHeader]: injectedValue, + }, }, }, - }, - ], + ], + }, }, }, }) diff --git a/packages/python-sdk/e2b/__init__.py b/packages/python-sdk/e2b/__init__.py index 1019809f8e..c8d17faaee 100644 --- a/packages/python-sdk/e2b/__init__.py +++ b/packages/python-sdk/e2b/__init__.py @@ -75,17 +75,17 @@ GitHubMcpServer, GitHubMcpServerConfig, McpServer, - SandboxFirewall, - SandboxFirewallRule, - SandboxFirewallRuleTransform, SandboxInfo, SandboxInfoLifecycle, SandboxMetrics, SandboxLifecycle, SandboxNetworkInfo, SandboxNetworkOpts, + SandboxNetworkRule, + SandboxNetworkRules, SandboxNetworkSelector, SandboxNetworkSelectorContext, + SandboxNetworkTransform, SandboxQuery, SandboxState, SnapshotInfo, @@ -192,9 +192,9 @@ "SandboxNetworkInfo", "SandboxNetworkSelector", "SandboxNetworkSelectorContext", - "SandboxFirewall", - "SandboxFirewallRule", - "SandboxFirewallRuleTransform", + "SandboxNetworkRule", + "SandboxNetworkRules", + "SandboxNetworkTransform", "SandboxLifecycle", "ALL_TRAFFIC", # Snapshot diff --git a/packages/python-sdk/e2b/api/client/api/sandboxes/put_sandboxes_sandbox_id_network.py b/packages/python-sdk/e2b/api/client/api/sandboxes/put_sandboxes_sandbox_id_network.py new file mode 100644 index 0000000000..cf6be790d6 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/sandboxes/put_sandboxes_sandbox_id_network.py @@ -0,0 +1,193 @@ +from http import HTTPStatus +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.put_sandboxes_sandbox_id_network_body import ( + PutSandboxesSandboxIDNetworkBody, +) +from ...types import Response + + +def _get_kwargs( + sandbox_id: str, + *, + body: PutSandboxesSandboxIDNetworkBody, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "put", + "url": f"/sandboxes/{sandbox_id}/network", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, Error]]: + if response.status_code == 204: + response_204 = cast(Any, None) + return response_204 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 409: + response_409 = Error.from_dict(response.json()) + + return response_409 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, Error]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: PutSandboxesSandboxIDNetworkBody, +) -> Response[Union[Any, Error]]: + """Update the network configuration for a running sandbox. Replaces the current egress rules with the + provided configuration. Omitting both fields clears all egress rules. + + Args: + sandbox_id (str): + body (PutSandboxesSandboxIDNetworkBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: PutSandboxesSandboxIDNetworkBody, +) -> Optional[Union[Any, Error]]: + """Update the network configuration for a running sandbox. Replaces the current egress rules with the + provided configuration. Omitting both fields clears all egress rules. + + Args: + sandbox_id (str): + body (PutSandboxesSandboxIDNetworkBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return sync_detailed( + sandbox_id=sandbox_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: PutSandboxesSandboxIDNetworkBody, +) -> Response[Union[Any, Error]]: + """Update the network configuration for a running sandbox. Replaces the current egress rules with the + provided configuration. Omitting both fields clears all egress rules. + + Args: + sandbox_id (str): + body (PutSandboxesSandboxIDNetworkBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: PutSandboxesSandboxIDNetworkBody, +) -> Optional[Union[Any, Error]]: + """Update the network configuration for a running sandbox. Replaces the current egress rules with the + provided configuration. Omitting both fields clears all egress rules. + + Args: + sandbox_id (str): + body (PutSandboxesSandboxIDNetworkBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return ( + await asyncio_detailed( + sandbox_id=sandbox_id, + client=client, + body=body, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/models/__init__.py b/packages/python-sdk/e2b/api/client/models/__init__.py index c5e872afb0..365bfe9a29 100644 --- a/packages/python-sdk/e2b/api/client/models/__init__.py +++ b/packages/python-sdk/e2b/api/client/models/__init__.py @@ -42,14 +42,14 @@ PostSandboxesSandboxIDSnapshotsBody, ) from .post_sandboxes_sandbox_id_timeout_body import PostSandboxesSandboxIDTimeoutBody +from .put_sandboxes_sandbox_id_network_body import PutSandboxesSandboxIDNetworkBody +from .put_sandboxes_sandbox_id_network_body_rules import ( + PutSandboxesSandboxIDNetworkBodyRules, +) from .resumed_sandbox import ResumedSandbox from .sandbox import Sandbox from .sandbox_auto_resume_config import SandboxAutoResumeConfig from .sandbox_detail import SandboxDetail -from .sandbox_firewall import SandboxFirewall -from .sandbox_firewall_rule import SandboxFirewallRule -from .sandbox_firewall_rule_transform import SandboxFirewallRuleTransform -from .sandbox_firewall_rule_transform_headers import SandboxFirewallRuleTransformHeaders from .sandbox_lifecycle import SandboxLifecycle from .sandbox_log import SandboxLog from .sandbox_log_entry import SandboxLogEntry @@ -58,6 +58,10 @@ from .sandbox_logs_v2_response import SandboxLogsV2Response from .sandbox_metric import SandboxMetric from .sandbox_network_config import SandboxNetworkConfig +from .sandbox_network_config_rules import SandboxNetworkConfigRules +from .sandbox_network_rule import SandboxNetworkRule +from .sandbox_network_transform import SandboxNetworkTransform +from .sandbox_network_transform_headers import SandboxNetworkTransformHeaders from .sandbox_on_timeout import SandboxOnTimeout from .sandbox_state import SandboxState from .sandbox_volume_mount import SandboxVolumeMount @@ -129,15 +133,13 @@ "PostSandboxesSandboxIDRefreshesBody", "PostSandboxesSandboxIDSnapshotsBody", "PostSandboxesSandboxIDTimeoutBody", + "PutSandboxesSandboxIDNetworkBody", + "PutSandboxesSandboxIDNetworkBodyRules", "ResumedSandbox", "Sandbox", "SandboxAutoResumeConfig", "SandboxDetail", "SandboxesWithMetrics", - "SandboxFirewall", - "SandboxFirewallRule", - "SandboxFirewallRuleTransform", - "SandboxFirewallRuleTransformHeaders", "SandboxLifecycle", "SandboxLog", "SandboxLogEntry", @@ -146,6 +148,10 @@ "SandboxLogsV2Response", "SandboxMetric", "SandboxNetworkConfig", + "SandboxNetworkConfigRules", + "SandboxNetworkRule", + "SandboxNetworkTransform", + "SandboxNetworkTransformHeaders", "SandboxOnTimeout", "SandboxState", "SandboxVolumeMount", diff --git a/packages/python-sdk/e2b/api/client/models/new_sandbox.py b/packages/python-sdk/e2b/api/client/models/new_sandbox.py index 0d7c808ddf..8703ef5692 100644 --- a/packages/python-sdk/e2b/api/client/models/new_sandbox.py +++ b/packages/python-sdk/e2b/api/client/models/new_sandbox.py @@ -9,7 +9,6 @@ if TYPE_CHECKING: from ..models.mcp_type_0 import McpType0 from ..models.sandbox_auto_resume_config import SandboxAutoResumeConfig - from ..models.sandbox_firewall import SandboxFirewall from ..models.sandbox_network_config import SandboxNetworkConfig from ..models.sandbox_volume_mount import SandboxVolumeMount @@ -27,9 +26,6 @@ class NewSandbox: auto_pause (Union[Unset, bool]): Automatically pauses the sandbox after the timeout Default: False. auto_resume (Union[Unset, SandboxAutoResumeConfig]): Auto-resume configuration for paused sandboxes. env_vars (Union[Unset, Any]): - firewall (Union[Unset, SandboxFirewall]): Map of host to ordered list of firewall rules applied to outbound - requests for that host. Registering a host here does not allow egress on its own; the host must also appear in - network.allowOut. mcp (Union['McpType0', None, Unset]): MCP configuration for the sandbox metadata (Union[Unset, Any]): network (Union[Unset, SandboxNetworkConfig]): @@ -43,7 +39,6 @@ class NewSandbox: auto_pause: Union[Unset, bool] = False auto_resume: Union[Unset, "SandboxAutoResumeConfig"] = UNSET env_vars: Union[Unset, Any] = UNSET - firewall: Union[Unset, "SandboxFirewall"] = UNSET mcp: Union["McpType0", None, Unset] = UNSET metadata: Union[Unset, Any] = UNSET network: Union[Unset, "SandboxNetworkConfig"] = UNSET @@ -67,10 +62,6 @@ def to_dict(self) -> dict[str, Any]: env_vars = self.env_vars - firewall: Union[Unset, dict[str, Any]] = UNSET - if not isinstance(self.firewall, Unset): - firewall = self.firewall.to_dict() - mcp: Union[None, Unset, dict[str, Any]] if isinstance(self.mcp, Unset): mcp = UNSET @@ -111,8 +102,6 @@ def to_dict(self) -> dict[str, Any]: field_dict["autoResume"] = auto_resume if env_vars is not UNSET: field_dict["envVars"] = env_vars - if firewall is not UNSET: - field_dict["firewall"] = firewall if mcp is not UNSET: field_dict["mcp"] = mcp if metadata is not UNSET: @@ -132,7 +121,6 @@ def to_dict(self) -> dict[str, Any]: def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: from ..models.mcp_type_0 import McpType0 from ..models.sandbox_auto_resume_config import SandboxAutoResumeConfig - from ..models.sandbox_firewall import SandboxFirewall from ..models.sandbox_network_config import SandboxNetworkConfig from ..models.sandbox_volume_mount import SandboxVolumeMount @@ -152,13 +140,6 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: env_vars = d.pop("envVars", UNSET) - _firewall = d.pop("firewall", UNSET) - firewall: Union[Unset, SandboxFirewall] - if isinstance(_firewall, Unset): - firewall = UNSET - else: - firewall = SandboxFirewall.from_dict(_firewall) - def _parse_mcp(data: object) -> Union["McpType0", None, Unset]: if data is None: return data @@ -202,7 +183,6 @@ def _parse_mcp(data: object) -> Union["McpType0", None, Unset]: auto_pause=auto_pause, auto_resume=auto_resume, env_vars=env_vars, - firewall=firewall, mcp=mcp, metadata=metadata, network=network, diff --git a/packages/python-sdk/e2b/api/client/models/node.py b/packages/python-sdk/e2b/api/client/models/node.py index fc60b7fc20..f7d2a47f6d 100644 --- a/packages/python-sdk/e2b/api/client/models/node.py +++ b/packages/python-sdk/e2b/api/client/models/node.py @@ -28,7 +28,10 @@ class Node: sandbox_count (int): Number of sandboxes running on the node sandbox_starting_count (int): Number of starting Sandboxes service_instance_id (str): Service instance identifier of the node - status (NodeStatus): Status of the node + status (NodeStatus): Status of the node. + - draining: the node is bound to be shut down. It will not accept new sandboxes and will stop once all existing + sandboxes are done. + - standby: the node is not actively used, but it can return to ready and continue serving traffic. version (str): Version of the orchestrator """ diff --git a/packages/python-sdk/e2b/api/client/models/node_detail.py b/packages/python-sdk/e2b/api/client/models/node_detail.py index 03c7362803..a5c1f61d3d 100644 --- a/packages/python-sdk/e2b/api/client/models/node_detail.py +++ b/packages/python-sdk/e2b/api/client/models/node_detail.py @@ -28,7 +28,10 @@ class NodeDetail: metrics (NodeMetrics): Node metrics sandbox_count (int): Number of sandboxes running on the node service_instance_id (str): Service instance identifier of the node - status (NodeStatus): Status of the node + status (NodeStatus): Status of the node. + - draining: the node is bound to be shut down. It will not accept new sandboxes and will stop once all existing + sandboxes are done. + - standby: the node is not actively used, but it can return to ready and continue serving traffic. version (str): Version of the orchestrator """ diff --git a/packages/python-sdk/e2b/api/client/models/node_status.py b/packages/python-sdk/e2b/api/client/models/node_status.py index 4529e3b542..9f7aa1f1b1 100644 --- a/packages/python-sdk/e2b/api/client/models/node_status.py +++ b/packages/python-sdk/e2b/api/client/models/node_status.py @@ -5,6 +5,7 @@ class NodeStatus(str, Enum): CONNECTING = "connecting" DRAINING = "draining" READY = "ready" + STANDBY = "standby" UNHEALTHY = "unhealthy" def __str__(self) -> str: diff --git a/packages/python-sdk/e2b/api/client/models/node_status_change.py b/packages/python-sdk/e2b/api/client/models/node_status_change.py index b628e429c6..06d09a1bb1 100644 --- a/packages/python-sdk/e2b/api/client/models/node_status_change.py +++ b/packages/python-sdk/e2b/api/client/models/node_status_change.py @@ -15,7 +15,10 @@ class NodeStatusChange: """ Attributes: - status (NodeStatus): Status of the node + status (NodeStatus): Status of the node. + - draining: the node is bound to be shut down. It will not accept new sandboxes and will stop once all existing + sandboxes are done. + - standby: the node is not actively used, but it can return to ready and continue serving traffic. cluster_id (Union[Unset, UUID]): Identifier of the cluster """ diff --git a/packages/python-sdk/e2b/api/client/models/put_sandboxes_sandbox_id_network_body.py b/packages/python-sdk/e2b/api/client/models/put_sandboxes_sandbox_id_network_body.py new file mode 100644 index 0000000000..c37a89368a --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/put_sandboxes_sandbox_id_network_body.py @@ -0,0 +1,112 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.put_sandboxes_sandbox_id_network_body_rules import ( + PutSandboxesSandboxIDNetworkBodyRules, + ) + + +T = TypeVar("T", bound="PutSandboxesSandboxIDNetworkBody") + + +@_attrs_define +class PutSandboxesSandboxIDNetworkBody: + """ + Attributes: + allow_out (Union[Unset, list[str]]): List of allowed destinations for egress traffic. Each entry can be a CIDR + block (e.g. "8.8.8.8/32"), a bare IP address (e.g. "8.8.8.8"), or a domain name (e.g. "example.com", + "*.example.com"). Allowed entries always take precedence over denied entries. + allow_internet_access (Union[Unset, bool]): Allow sandbox to access the internet. When set to false, it behaves + the same as specifying denyOut to 0.0.0.0/0 in the network config. + deny_out (Union[Unset, list[str]]): List of denied CIDR blocks or IP addresses for egress traffic. Domain names + are not supported for deny rules. + rules (Union[Unset, PutSandboxesSandboxIDNetworkBodyRules]): Per-domain transform rules. Replaces all existing + rules when provided. + """ + + allow_out: Union[Unset, list[str]] = UNSET + allow_internet_access: Union[Unset, bool] = UNSET + deny_out: Union[Unset, list[str]] = UNSET + rules: Union[Unset, "PutSandboxesSandboxIDNetworkBodyRules"] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + allow_out: Union[Unset, list[str]] = UNSET + if not isinstance(self.allow_out, Unset): + allow_out = self.allow_out + + allow_internet_access = self.allow_internet_access + + deny_out: Union[Unset, list[str]] = UNSET + if not isinstance(self.deny_out, Unset): + deny_out = self.deny_out + + rules: Union[Unset, dict[str, Any]] = UNSET + if not isinstance(self.rules, Unset): + rules = self.rules.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if allow_out is not UNSET: + field_dict["allowOut"] = allow_out + if allow_internet_access is not UNSET: + field_dict["allow_internet_access"] = allow_internet_access + if deny_out is not UNSET: + field_dict["denyOut"] = deny_out + if rules is not UNSET: + field_dict["rules"] = rules + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.put_sandboxes_sandbox_id_network_body_rules import ( + PutSandboxesSandboxIDNetworkBodyRules, + ) + + d = dict(src_dict) + allow_out = cast(list[str], d.pop("allowOut", UNSET)) + + allow_internet_access = d.pop("allow_internet_access", UNSET) + + deny_out = cast(list[str], d.pop("denyOut", UNSET)) + + _rules = d.pop("rules", UNSET) + rules: Union[Unset, PutSandboxesSandboxIDNetworkBodyRules] + if isinstance(_rules, Unset): + rules = UNSET + else: + rules = PutSandboxesSandboxIDNetworkBodyRules.from_dict(_rules) + + put_sandboxes_sandbox_id_network_body = cls( + allow_out=allow_out, + allow_internet_access=allow_internet_access, + deny_out=deny_out, + rules=rules, + ) + + put_sandboxes_sandbox_id_network_body.additional_properties = d + return put_sandboxes_sandbox_id_network_body + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_firewall.py b/packages/python-sdk/e2b/api/client/models/put_sandboxes_sandbox_id_network_body_rules.py similarity index 65% rename from packages/python-sdk/e2b/api/client/models/sandbox_firewall.py rename to packages/python-sdk/e2b/api/client/models/put_sandboxes_sandbox_id_network_body_rules.py index ef63b35ad5..1af75b3cb3 100644 --- a/packages/python-sdk/e2b/api/client/models/sandbox_firewall.py +++ b/packages/python-sdk/e2b/api/client/models/put_sandboxes_sandbox_id_network_body_rules.py @@ -5,20 +5,17 @@ from attrs import field as _attrs_field if TYPE_CHECKING: - from ..models.sandbox_firewall_rule import SandboxFirewallRule + from ..models.sandbox_network_rule import SandboxNetworkRule -T = TypeVar("T", bound="SandboxFirewall") +T = TypeVar("T", bound="PutSandboxesSandboxIDNetworkBodyRules") @_attrs_define -class SandboxFirewall: - """Map of host to ordered list of firewall rules applied to outbound requests for that host. Registering a host here - does not allow egress on its own; the host must also appear in network.allowOut. +class PutSandboxesSandboxIDNetworkBodyRules: + """Per-domain transform rules. Replaces all existing rules when provided.""" - """ - - additional_properties: dict[str, list["SandboxFirewallRule"]] = _attrs_field( + additional_properties: dict[str, list["SandboxNetworkRule"]] = _attrs_field( init=False, factory=dict ) @@ -34,17 +31,17 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - from ..models.sandbox_firewall_rule import SandboxFirewallRule + from ..models.sandbox_network_rule import SandboxNetworkRule d = dict(src_dict) - sandbox_firewall = cls() + put_sandboxes_sandbox_id_network_body_rules = cls() additional_properties = {} for prop_name, prop_dict in d.items(): additional_property = [] _additional_property = prop_dict for additional_property_item_data in _additional_property: - additional_property_item = SandboxFirewallRule.from_dict( + additional_property_item = SandboxNetworkRule.from_dict( additional_property_item_data ) @@ -52,17 +49,19 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: additional_properties[prop_name] = additional_property - sandbox_firewall.additional_properties = additional_properties - return sandbox_firewall + put_sandboxes_sandbox_id_network_body_rules.additional_properties = ( + additional_properties + ) + return put_sandboxes_sandbox_id_network_body_rules @property def additional_keys(self) -> list[str]: return list(self.additional_properties.keys()) - def __getitem__(self, key: str) -> list["SandboxFirewallRule"]: + def __getitem__(self, key: str) -> list["SandboxNetworkRule"]: return self.additional_properties[key] - def __setitem__(self, key: str, value: list["SandboxFirewallRule"]) -> None: + def __setitem__(self, key: str, value: list["SandboxNetworkRule"]) -> None: self.additional_properties[key] = value def __delitem__(self, key: str) -> None: diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_detail.py b/packages/python-sdk/e2b/api/client/models/sandbox_detail.py index 7fee681b27..1078d2b7aa 100644 --- a/packages/python-sdk/e2b/api/client/models/sandbox_detail.py +++ b/packages/python-sdk/e2b/api/client/models/sandbox_detail.py @@ -10,7 +10,6 @@ from ..types import UNSET, Unset if TYPE_CHECKING: - from ..models.sandbox_firewall import SandboxFirewall from ..models.sandbox_lifecycle import SandboxLifecycle from ..models.sandbox_network_config import SandboxNetworkConfig from ..models.sandbox_volume_mount import SandboxVolumeMount @@ -38,9 +37,6 @@ class SandboxDetail: the sandbox. Null means it was not explicitly set. domain (Union[None, Unset, str]): Base domain where the sandbox traffic is accessible envd_access_token (Union[Unset, str]): Access token used for envd communication - firewall (Union[Unset, SandboxFirewall]): Map of host to ordered list of firewall rules applied to outbound - requests for that host. Registering a host here does not allow egress on its own; the host must also appear in - network.allowOut. lifecycle (Union[Unset, SandboxLifecycle]): Sandbox lifecycle policy returned by sandbox info. metadata (Union[Unset, Any]): network (Union[Unset, SandboxNetworkConfig]): @@ -61,7 +57,6 @@ class SandboxDetail: allow_internet_access: Union[None, Unset, bool] = UNSET domain: Union[None, Unset, str] = UNSET envd_access_token: Union[Unset, str] = UNSET - firewall: Union[Unset, "SandboxFirewall"] = UNSET lifecycle: Union[Unset, "SandboxLifecycle"] = UNSET metadata: Union[Unset, Any] = UNSET network: Union[Unset, "SandboxNetworkConfig"] = UNSET @@ -105,10 +100,6 @@ def to_dict(self) -> dict[str, Any]: envd_access_token = self.envd_access_token - firewall: Union[Unset, dict[str, Any]] = UNSET - if not isinstance(self.firewall, Unset): - firewall = self.firewall.to_dict() - lifecycle: Union[Unset, dict[str, Any]] = UNSET if not isinstance(self.lifecycle, Unset): lifecycle = self.lifecycle.to_dict() @@ -150,8 +141,6 @@ def to_dict(self) -> dict[str, Any]: field_dict["domain"] = domain if envd_access_token is not UNSET: field_dict["envdAccessToken"] = envd_access_token - if firewall is not UNSET: - field_dict["firewall"] = firewall if lifecycle is not UNSET: field_dict["lifecycle"] = lifecycle if metadata is not UNSET: @@ -165,7 +154,6 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - from ..models.sandbox_firewall import SandboxFirewall from ..models.sandbox_lifecycle import SandboxLifecycle from ..models.sandbox_network_config import SandboxNetworkConfig from ..models.sandbox_volume_mount import SandboxVolumeMount @@ -215,13 +203,6 @@ def _parse_domain(data: object) -> Union[None, Unset, str]: envd_access_token = d.pop("envdAccessToken", UNSET) - _firewall = d.pop("firewall", UNSET) - firewall: Union[Unset, SandboxFirewall] - if isinstance(_firewall, Unset): - firewall = UNSET - else: - firewall = SandboxFirewall.from_dict(_firewall) - _lifecycle = d.pop("lifecycle", UNSET) lifecycle: Union[Unset, SandboxLifecycle] if isinstance(_lifecycle, Unset): @@ -260,7 +241,6 @@ def _parse_domain(data: object) -> Union[None, Unset, str]: allow_internet_access=allow_internet_access, domain=domain, envd_access_token=envd_access_token, - firewall=firewall, lifecycle=lifecycle, metadata=metadata, network=network, diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_metric.py b/packages/python-sdk/e2b/api/client/models/sandbox_metric.py index eb7442fd9e..cbfb9eeb76 100644 --- a/packages/python-sdk/e2b/api/client/models/sandbox_metric.py +++ b/packages/python-sdk/e2b/api/client/models/sandbox_metric.py @@ -18,6 +18,7 @@ class SandboxMetric: cpu_used_pct (float): CPU usage percentage disk_total (int): Total disk space in bytes disk_used (int): Disk used in bytes + mem_cache (int): Cached memory (page cache) in bytes mem_total (int): Total memory in bytes mem_used (int): Memory used in bytes timestamp (datetime.datetime): Timestamp of the metric entry @@ -28,6 +29,7 @@ class SandboxMetric: cpu_used_pct: float disk_total: int disk_used: int + mem_cache: int mem_total: int mem_used: int timestamp: datetime.datetime @@ -43,6 +45,8 @@ def to_dict(self) -> dict[str, Any]: disk_used = self.disk_used + mem_cache = self.mem_cache + mem_total = self.mem_total mem_used = self.mem_used @@ -59,6 +63,7 @@ def to_dict(self) -> dict[str, Any]: "cpuUsedPct": cpu_used_pct, "diskTotal": disk_total, "diskUsed": disk_used, + "memCache": mem_cache, "memTotal": mem_total, "memUsed": mem_used, "timestamp": timestamp, @@ -79,6 +84,8 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: disk_used = d.pop("diskUsed") + mem_cache = d.pop("memCache") + mem_total = d.pop("memTotal") mem_used = d.pop("memUsed") @@ -92,6 +99,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: cpu_used_pct=cpu_used_pct, disk_total=disk_total, disk_used=disk_used, + mem_cache=mem_cache, mem_total=mem_total, mem_used=mem_used, timestamp=timestamp, diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py b/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py index d10d85f175..c7a99c127b 100644 --- a/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py +++ b/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py @@ -1,11 +1,15 @@ from collections.abc import Mapping -from typing import Any, TypeVar, Union, cast +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast from attrs import define as _attrs_define from attrs import field as _attrs_field from ..types import UNSET, Unset +if TYPE_CHECKING: + from ..models.sandbox_network_config_rules import SandboxNetworkConfigRules + + T = TypeVar("T", bound="SandboxNetworkConfig") @@ -13,18 +17,24 @@ class SandboxNetworkConfig: """ Attributes: - allow_out (Union[Unset, list[str]]): List of allowed CIDR blocks, IP addresses, or hostnames for egress traffic. - Allowed entries always take precedence over denied ones. + allow_out (Union[Unset, list[str]]): List of allowed destinations for egress traffic. Each entry can be a CIDR + block (e.g. "8.8.8.8/32"), a bare IP address (e.g. "8.8.8.8"), or a domain name (e.g. "example.com", + "*.example.com"). Allowed entries always take precedence over denied entries. allow_public_traffic (Union[Unset, bool]): Specify if the sandbox URLs should be accessible only with authentication. Default: True. - deny_out (Union[Unset, list[str]]): List of denied CIDR blocks, IP addresses, or hostnames for egress traffic. + deny_out (Union[Unset, list[str]]): List of denied CIDR blocks or IP addresses for egress traffic. Domain names + are not supported for deny rules. mask_request_host (Union[Unset, str]): Specify host mask which will be used for all sandbox requests + rules (Union[Unset, SandboxNetworkConfigRules]): Per-domain transform rules applied to matching egress + HTTP/HTTPS requests. Keys are domains (e.g. "api.example.com", "example.com"). A domain listed here is not + automatically allowed - use allowOut to permit the traffic. """ allow_out: Union[Unset, list[str]] = UNSET allow_public_traffic: Union[Unset, bool] = True deny_out: Union[Unset, list[str]] = UNSET mask_request_host: Union[Unset, str] = UNSET + rules: Union[Unset, "SandboxNetworkConfigRules"] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: @@ -40,6 +50,10 @@ def to_dict(self) -> dict[str, Any]: mask_request_host = self.mask_request_host + rules: Union[Unset, dict[str, Any]] = UNSET + if not isinstance(self.rules, Unset): + rules = self.rules.to_dict() + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update({}) @@ -51,11 +65,15 @@ def to_dict(self) -> dict[str, Any]: field_dict["denyOut"] = deny_out if mask_request_host is not UNSET: field_dict["maskRequestHost"] = mask_request_host + if rules is not UNSET: + field_dict["rules"] = rules return field_dict @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.sandbox_network_config_rules import SandboxNetworkConfigRules + d = dict(src_dict) allow_out = cast(list[str], d.pop("allowOut", UNSET)) @@ -65,11 +83,19 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: mask_request_host = d.pop("maskRequestHost", UNSET) + _rules = d.pop("rules", UNSET) + rules: Union[Unset, SandboxNetworkConfigRules] + if isinstance(_rules, Unset): + rules = UNSET + else: + rules = SandboxNetworkConfigRules.from_dict(_rules) + sandbox_network_config = cls( allow_out=allow_out, allow_public_traffic=allow_public_traffic, deny_out=deny_out, mask_request_host=mask_request_host, + rules=rules, ) sandbox_network_config.additional_properties = d diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_network_config_rules.py b/packages/python-sdk/e2b/api/client/models/sandbox_network_config_rules.py new file mode 100644 index 0000000000..aeece3851b --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_network_config_rules.py @@ -0,0 +1,72 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.sandbox_network_rule import SandboxNetworkRule + + +T = TypeVar("T", bound="SandboxNetworkConfigRules") + + +@_attrs_define +class SandboxNetworkConfigRules: + """Per-domain transform rules applied to matching egress HTTP/HTTPS requests. Keys are domains (e.g. "api.example.com", + "example.com"). A domain listed here is not automatically allowed - use allowOut to permit the traffic. + + """ + + additional_properties: dict[str, list["SandboxNetworkRule"]] = _attrs_field( + init=False, factory=dict + ) + + def to_dict(self) -> dict[str, Any]: + field_dict: dict[str, Any] = {} + for prop_name, prop in self.additional_properties.items(): + field_dict[prop_name] = [] + for additional_property_item_data in prop: + additional_property_item = additional_property_item_data.to_dict() + field_dict[prop_name].append(additional_property_item) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.sandbox_network_rule import SandboxNetworkRule + + d = dict(src_dict) + sandbox_network_config_rules = cls() + + additional_properties = {} + for prop_name, prop_dict in d.items(): + additional_property = [] + _additional_property = prop_dict + for additional_property_item_data in _additional_property: + additional_property_item = SandboxNetworkRule.from_dict( + additional_property_item_data + ) + + additional_property.append(additional_property_item) + + additional_properties[prop_name] = additional_property + + sandbox_network_config_rules.additional_properties = additional_properties + return sandbox_network_config_rules + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> list["SandboxNetworkRule"]: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: list["SandboxNetworkRule"]) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_firewall_rule.py b/packages/python-sdk/e2b/api/client/models/sandbox_network_rule.py similarity index 65% rename from packages/python-sdk/e2b/api/client/models/sandbox_firewall_rule.py rename to packages/python-sdk/e2b/api/client/models/sandbox_network_rule.py index 16c6c2ce04..f1d0a306ec 100644 --- a/packages/python-sdk/e2b/api/client/models/sandbox_firewall_rule.py +++ b/packages/python-sdk/e2b/api/client/models/sandbox_network_rule.py @@ -7,22 +7,22 @@ from ..types import UNSET, Unset if TYPE_CHECKING: - from ..models.sandbox_firewall_rule_transform import SandboxFirewallRuleTransform + from ..models.sandbox_network_transform import SandboxNetworkTransform -T = TypeVar("T", bound="SandboxFirewallRule") +T = TypeVar("T", bound="SandboxNetworkRule") @_attrs_define -class SandboxFirewallRule: - """Firewall rule applied to outbound requests matching the host it is registered under. +class SandboxNetworkRule: + """Transform rule applied to egress requests matching a domain pattern. Attributes: - transform (Union[Unset, SandboxFirewallRuleTransform]): Transform applied to egress requests matching a firewall - rule. + transform (Union[Unset, SandboxNetworkTransform]): Transformations applied to matching egress requests before + forwarding. """ - transform: Union[Unset, "SandboxFirewallRuleTransform"] = UNSET + transform: Union[Unset, "SandboxNetworkTransform"] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: @@ -40,24 +40,22 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - from ..models.sandbox_firewall_rule_transform import ( - SandboxFirewallRuleTransform, - ) + from ..models.sandbox_network_transform import SandboxNetworkTransform d = dict(src_dict) _transform = d.pop("transform", UNSET) - transform: Union[Unset, SandboxFirewallRuleTransform] + transform: Union[Unset, SandboxNetworkTransform] if isinstance(_transform, Unset): transform = UNSET else: - transform = SandboxFirewallRuleTransform.from_dict(_transform) + transform = SandboxNetworkTransform.from_dict(_transform) - sandbox_firewall_rule = cls( + sandbox_network_rule = cls( transform=transform, ) - sandbox_firewall_rule.additional_properties = d - return sandbox_firewall_rule + sandbox_network_rule.additional_properties = d + return sandbox_network_rule @property def additional_keys(self) -> list[str]: diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_firewall_rule_transform.py b/packages/python-sdk/e2b/api/client/models/sandbox_network_transform.py similarity index 60% rename from packages/python-sdk/e2b/api/client/models/sandbox_firewall_rule_transform.py rename to packages/python-sdk/e2b/api/client/models/sandbox_network_transform.py index 494921f18d..d754240c60 100644 --- a/packages/python-sdk/e2b/api/client/models/sandbox_firewall_rule_transform.py +++ b/packages/python-sdk/e2b/api/client/models/sandbox_network_transform.py @@ -7,24 +7,25 @@ from ..types import UNSET, Unset if TYPE_CHECKING: - from ..models.sandbox_firewall_rule_transform_headers import ( - SandboxFirewallRuleTransformHeaders, + from ..models.sandbox_network_transform_headers import ( + SandboxNetworkTransformHeaders, ) -T = TypeVar("T", bound="SandboxFirewallRuleTransform") +T = TypeVar("T", bound="SandboxNetworkTransform") @_attrs_define -class SandboxFirewallRuleTransform: - """Transform applied to egress requests matching a firewall rule. +class SandboxNetworkTransform: + """Transformations applied to matching egress requests before forwarding. Attributes: - headers (Union[Unset, SandboxFirewallRuleTransformHeaders]): Headers to inject into the outbound request. Values - override any headers already present. + headers (Union[Unset, SandboxNetworkTransformHeaders]): HTTP headers to inject or override in matching requests. + An existing header with the same name is replaced. Values are plain strings; secret resolution happens client- + side before sending to the API. """ - headers: Union[Unset, "SandboxFirewallRuleTransformHeaders"] = UNSET + headers: Union[Unset, "SandboxNetworkTransformHeaders"] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: @@ -42,24 +43,24 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - from ..models.sandbox_firewall_rule_transform_headers import ( - SandboxFirewallRuleTransformHeaders, + from ..models.sandbox_network_transform_headers import ( + SandboxNetworkTransformHeaders, ) d = dict(src_dict) _headers = d.pop("headers", UNSET) - headers: Union[Unset, SandboxFirewallRuleTransformHeaders] + headers: Union[Unset, SandboxNetworkTransformHeaders] if isinstance(_headers, Unset): headers = UNSET else: - headers = SandboxFirewallRuleTransformHeaders.from_dict(_headers) + headers = SandboxNetworkTransformHeaders.from_dict(_headers) - sandbox_firewall_rule_transform = cls( + sandbox_network_transform = cls( headers=headers, ) - sandbox_firewall_rule_transform.additional_properties = d - return sandbox_firewall_rule_transform + sandbox_network_transform.additional_properties = d + return sandbox_network_transform @property def additional_keys(self) -> list[str]: diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_firewall_rule_transform_headers.py b/packages/python-sdk/e2b/api/client/models/sandbox_network_transform_headers.py similarity index 68% rename from packages/python-sdk/e2b/api/client/models/sandbox_firewall_rule_transform_headers.py rename to packages/python-sdk/e2b/api/client/models/sandbox_network_transform_headers.py index 3577aba5a2..c2d7c1aaa2 100644 --- a/packages/python-sdk/e2b/api/client/models/sandbox_firewall_rule_transform_headers.py +++ b/packages/python-sdk/e2b/api/client/models/sandbox_network_transform_headers.py @@ -4,12 +4,15 @@ from attrs import define as _attrs_define from attrs import field as _attrs_field -T = TypeVar("T", bound="SandboxFirewallRuleTransformHeaders") +T = TypeVar("T", bound="SandboxNetworkTransformHeaders") @_attrs_define -class SandboxFirewallRuleTransformHeaders: - """Headers to inject into the outbound request. Values override any headers already present.""" +class SandboxNetworkTransformHeaders: + """HTTP headers to inject or override in matching requests. An existing header with the same name is replaced. Values + are plain strings; secret resolution happens client-side before sending to the API. + + """ additional_properties: dict[str, str] = _attrs_field(init=False, factory=dict) @@ -22,10 +25,10 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: d = dict(src_dict) - sandbox_firewall_rule_transform_headers = cls() + sandbox_network_transform_headers = cls() - sandbox_firewall_rule_transform_headers.additional_properties = d - return sandbox_firewall_rule_transform_headers + sandbox_network_transform_headers.additional_properties = d + return sandbox_network_transform_headers @property def additional_keys(self) -> list[str]: diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index d068653c8f..692fd4df4d 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -1,6 +1,17 @@ from dataclasses import dataclass, field from datetime import datetime -from typing import Any, Callable, Dict, List, Literal, Optional, TypedDict, Union, cast +from typing import ( + Any, + Callable, + Dict, + List, + Literal, + Mapping, + Optional, + TypedDict, + Union, + cast, +) from typing_extensions import NotRequired, Unpack @@ -8,9 +19,12 @@ from e2b.api.client.models import ( ListedSandbox, SandboxDetail, - SandboxFirewall as ClientSandboxFirewall, SandboxLifecycle as ClientSandboxLifecycle, SandboxNetworkConfig, + SandboxNetworkConfigRules, + SandboxNetworkRule as ClientSandboxNetworkRule, + SandboxNetworkTransform as ClientSandboxNetworkTransform, + SandboxNetworkTransformHeaders as ClientSandboxNetworkTransformHeaders, SandboxState, ) from e2b.api.client.types import Unset @@ -47,9 +61,9 @@ class GitHubMcpServerConfig(TypedDict): McpServer = Union[BaseMcpServer, GitHubMcpServer] -class SandboxFirewallRuleTransform(TypedDict): +class SandboxNetworkTransform(TypedDict): """ - Transform applied to outbound requests matching a `SandboxFirewallRule`. + Transform applied to egress requests matching a :class:`SandboxNetworkRule`. """ headers: NotRequired[Dict[str, str]] @@ -59,21 +73,20 @@ class SandboxFirewallRuleTransform(TypedDict): """ -class SandboxFirewallRule(TypedDict): +class SandboxNetworkRule(TypedDict): """ - Firewall rule applied to outbound requests matching the host it is - registered under in `firewall`. + Per-domain rule applied to egress requests. """ - transform: NotRequired[SandboxFirewallRuleTransform] + transform: NotRequired[SandboxNetworkTransform] """Transform applied to requests matching this rule.""" -SandboxFirewall = Dict[str, List[SandboxFirewallRule]] +SandboxNetworkRules = Dict[str, List[SandboxNetworkRule]] """ -Map of host (or CIDR / IP) to ordered list of firewall rules applied to -outbound requests for that host. Registering a host here does not allow egress -on its own — the host must also appear in ``SandboxNetworkOpts.allow_out``. +Map of host (or CIDR / IP) to ordered list of rules applied to outbound +requests for that host. Registering a host here does not allow egress on its +own — the host must also appear in ``SandboxNetworkOpts.allow_out``. """ @@ -83,11 +96,11 @@ class SandboxNetworkSelectorContext: Context passed to ``allow_out``/``deny_out`` callables. """ - firewall_hosts: List[str] - """Hosts registered in the top-level ``firewall`` argument.""" + all_traffic: str + """All traffic sentinel — equivalent to ``"0.0.0.0/0"``.""" - all_hosts: List[str] - """All traffic — equivalent to ``["0.0.0.0/0"]``.""" + rules: Mapping[str, List[SandboxNetworkRule]] + """Rules registered in :attr:`SandboxNetworkOpts.rules`.""" SandboxNetworkSelector = Union[ @@ -113,14 +126,13 @@ class SandboxNetworkOpts(TypedDict): Accepts either a static list of CIDR blocks / IP addresses / hostnames, or a callable that receives a :class:`SandboxNetworkSelectorContext` and - returns the same. ``ctx.firewall_hosts`` is the list of hosts registered - in the top-level ``firewall`` argument; ``ctx.all_hosts`` is - ``["0.0.0.0/0"]``. + returns the same. ``ctx.all_traffic`` is ``"0.0.0.0/0"``; ``ctx.rules`` is + a read-only view of :attr:`rules`. Examples: - Static list: ``["1.1.1.1", "8.8.8.0/24"]`` - - Allow only firewall-registered hosts: - ``lambda ctx: ctx.firewall_hosts`` + - Allow only rule-registered hosts: + ``lambda ctx: list(ctx.rules.keys())`` """ deny_out: NotRequired[SandboxNetworkSelector] @@ -131,7 +143,18 @@ class SandboxNetworkOpts(TypedDict): Examples: - Static list: ``["1.1.1.1", "8.8.8.0/24"]`` - - Block all egress: ``lambda ctx: ctx.all_hosts`` + - Block all egress: ``lambda ctx: [ctx.all_traffic]`` + """ + + rules: NotRequired[SandboxNetworkRules] + """ + Per-domain transform rules applied to matching egress HTTP/HTTPS + requests. Keys are domains (e.g. ``"api.example.com"``); values are + ordered lists of :class:`SandboxNetworkRule`. + + Registering a host here does not allow egress on its own — the host must + also appear in ``allow_out``. Hosts registered here are exposed to the + ``allow_out``/``deny_out`` callables via ``ctx.rules``. """ allow_public_traffic: NotRequired[bool] @@ -159,6 +182,7 @@ class SandboxNetworkInfo(TypedDict, total=False): allow_out: List[str] deny_out: List[str] + rules: SandboxNetworkRules allow_public_traffic: bool mask_request_host: str @@ -197,43 +221,61 @@ class SandboxInfoLifecycle(TypedDict): """ -_ALL_TRAFFIC_HOSTS: List[str] = [ALL_TRAFFIC] - - def _resolve_network_selector( selector: Optional[SandboxNetworkSelector], - firewall_hosts: List[str], + rules: Mapping[str, List[SandboxNetworkRule]], ) -> Optional[List[str]]: if selector is None: return None if callable(selector): - ctx = SandboxNetworkSelectorContext( - firewall_hosts=firewall_hosts, - all_hosts=list(_ALL_TRAFFIC_HOSTS), - ) + ctx = SandboxNetworkSelectorContext(all_traffic=ALL_TRAFFIC, rules=rules) return list(selector(ctx)) return list(selector) +def _build_client_rules(rules: SandboxNetworkRules) -> SandboxNetworkConfigRules: + client_rules = SandboxNetworkConfigRules() + for host, host_rules in rules.items(): + converted: List[ClientSandboxNetworkRule] = [] + for rule in host_rules: + transform = rule.get("transform") + if transform is None: + converted.append(ClientSandboxNetworkRule()) + continue + + client_transform = ClientSandboxNetworkTransform() + headers = transform.get("headers") + if headers: + client_headers = ClientSandboxNetworkTransformHeaders() + client_headers.additional_properties = dict(headers) + client_transform.headers = client_headers + + converted.append(ClientSandboxNetworkRule(transform=client_transform)) + client_rules.additional_properties[host] = converted + + return client_rules + + def build_network_config( network: Optional[SandboxNetworkOpts], - firewall: Optional[SandboxFirewall], ) -> Optional[Dict[str, Any]]: """Resolve a :class:`SandboxNetworkOpts` into the dict the API expects.""" if network is None: return None - firewall_hosts = list(firewall.keys()) if firewall else [] - allow_out = _resolve_network_selector(network.get("allow_out"), firewall_hosts) - deny_out = _resolve_network_selector(network.get("deny_out"), firewall_hosts) + rules = network.get("rules") or {} + allow_out = _resolve_network_selector(network.get("allow_out"), rules) + deny_out = _resolve_network_selector(network.get("deny_out"), rules) body: Dict[str, Any] = {} if allow_out is not None: body["allow_out"] = allow_out if deny_out is not None: body["deny_out"] = deny_out + if "rules" in network and network["rules"] is not None: + body["rules"] = _build_client_rules(network["rules"]) if "allow_public_traffic" in network: body["allow_public_traffic"] = network["allow_public_traffic"] if "mask_request_host" in network: @@ -242,41 +284,6 @@ def build_network_config( return body -def build_firewall_config( - firewall: Optional[SandboxFirewall], -) -> Optional[ClientSandboxFirewall]: - """Convert a :class:`SandboxFirewall` into the generated client model.""" - if firewall is None: - return None - - from e2b.api.client.models import ( - SandboxFirewallRule as ClientSandboxFirewallRule, - SandboxFirewallRuleTransform as ClientSandboxFirewallRuleTransform, - SandboxFirewallRuleTransformHeaders as ClientSandboxFirewallRuleTransformHeaders, - ) - - client_firewall = ClientSandboxFirewall() - for host, rules in firewall.items(): - client_rules: List[ClientSandboxFirewallRule] = [] - for rule in rules: - transform = rule.get("transform") - if transform is None: - client_rules.append(ClientSandboxFirewallRule()) - continue - - client_transform = ClientSandboxFirewallRuleTransform() - headers = transform.get("headers") - if headers: - client_headers = ClientSandboxFirewallRuleTransformHeaders() - client_headers.additional_properties = dict(headers) - client_transform.headers = client_headers - - client_rules.append(ClientSandboxFirewallRule(transform=client_transform)) - client_firewall.additional_properties[host] = client_rules - - return client_firewall - - def get_auto_resume_enabled(lifecycle: Optional[SandboxLifecycle]) -> Optional[bool]: if lifecycle is None or lifecycle.get("on_timeout") != "pause": return None @@ -296,6 +303,8 @@ def from_client_network_config( result["allow_out"] = list(network.allow_out) if not isinstance(network.deny_out, Unset): result["deny_out"] = list(network.deny_out) + if not isinstance(network.rules, Unset): + result["rules"] = cast(SandboxNetworkRules, network.rules.to_dict()) if not isinstance(network.allow_public_traffic, Unset): result["allow_public_traffic"] = network.allow_public_traffic if not isinstance(network.mask_request_host, Unset): @@ -304,15 +313,6 @@ def from_client_network_config( return result -def from_client_firewall( - firewall: Union[Unset, ClientSandboxFirewall], -) -> Optional[SandboxFirewall]: - if isinstance(firewall, Unset): - return None - - return cast(SandboxFirewall, firewall.to_dict()) - - def from_client_lifecycle( lifecycle: Union[Unset, ClientSandboxLifecycle], ) -> Optional[SandboxInfoLifecycle]: @@ -359,8 +359,6 @@ class SandboxInfo: """Whether internet access was explicitly enabled or disabled for the sandbox.""" network: Optional[SandboxNetworkInfo] = None """Sandbox network configuration.""" - firewall: Optional[SandboxFirewall] = None - """Per-host firewall rules registered for the sandbox.""" lifecycle: Optional[SandboxInfoLifecycle] = None """Sandbox lifecycle configuration.""" volume_mounts: List[Dict[str, str]] = field(default_factory=list) @@ -374,7 +372,6 @@ def _from_sandbox_data( sandbox_domain: Optional[str] = None, allow_internet_access: Optional[bool] = None, network: Optional[SandboxNetworkInfo] = None, - firewall: Optional[SandboxFirewall] = None, lifecycle: Optional[SandboxInfoLifecycle] = None, ): return cls( @@ -400,7 +397,6 @@ def _from_sandbox_data( _envd_access_token=envd_access_token, allow_internet_access=allow_internet_access, network=network, - firewall=firewall, lifecycle=lifecycle, ) @@ -428,7 +424,6 @@ def _from_sandbox_detail(cls, sandbox_detail: SandboxDetail): else None ), network=from_client_network_config(sandbox_detail.network), - firewall=from_client_firewall(sandbox_detail.firewall), lifecycle=from_client_lifecycle(sandbox_detail.lifecycle), ) diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 0160c82f93..c145c8c76d 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -21,7 +21,6 @@ from e2b.sandbox.main import SandboxOpts from e2b.sandbox.sandbox_api import ( McpServer, - SandboxFirewall, SandboxLifecycle, SandboxMetrics, SandboxNetworkOpts, @@ -178,7 +177,6 @@ async def create( allow_internet_access: bool = True, mcp: Optional[McpServer] = None, network: Optional[SandboxNetworkOpts] = None, - firewall: Optional[SandboxFirewall] = None, lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[SandboxAsyncVolumeMount] = None, **opts: Unpack[ApiParams], @@ -195,8 +193,7 @@ async def create( :param secure: Envd is secured with access token and cannot be used without it, defaults to `True`. :param allow_internet_access: Allow sandbox to access the internet, defaults to `True`. If set to `False`, it works the same as setting network `deny_out` to `[0.0.0.0/0]`. :param mcp: MCP server to enable in the sandbox - :param network: Sandbox network configuration. ``allow_out``/``deny_out`` may also be a callable receiving a :class:`SandboxNetworkSelectorContext` (``ctx.firewall_hosts``, ``ctx.all_hosts``) and returning a list of strings. - :param firewall: Per-host firewall rules applied to outbound requests. Hosts registered here are exposed to the network ``allow_out``/``deny_out`` callables via ``firewall_hosts`` but are not allowed by default — they must also appear in ``network.allow_out``. + :param network: Sandbox network configuration. ``allow_out``/``deny_out`` may also be a callable receiving a :class:`SandboxNetworkSelectorContext` (``ctx.all_traffic``, ``ctx.rules``) and returning a list of strings. Per-host transform rules are nested under ``network.rules``. :param lifecycle: Sandbox lifecycle configuration — ``on_timeout``: ``"kill"`` (default) or ``"pause"``; ``auto_resume``: ``False`` (default) or ``True`` (only when ``on_timeout="pause"``). Example: ``{"on_timeout": "pause", "auto_resume": True}`` :param volume_mounts: Dictionary mapping mount paths to AsyncVolume instances or volume names @@ -229,7 +226,6 @@ async def create( allow_internet_access=allow_internet_access, mcp=mcp, network=network, - firewall=firewall, lifecycle=lifecycle, volume_mounts=transformed_mounts, **opts, @@ -896,7 +892,6 @@ async def _create( secure: bool, mcp: Optional[McpServer] = None, network: Optional[SandboxNetworkOpts] = None, - firewall: Optional[SandboxFirewall] = None, lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[list] = None, **opts: Unpack[ApiParams], @@ -921,7 +916,6 @@ async def _create( allow_internet_access=allow_internet_access, mcp=mcp, network=network, - firewall=firewall, lifecycle=lifecycle, volume_mounts=volume_mounts, **opts, diff --git a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py index dfadf280f8..1b9f6dc324 100644 --- a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py @@ -37,9 +37,7 @@ ) from e2b.sandbox.main import SandboxBase from e2b.sandbox.sandbox_api import ( - SandboxFirewall, SandboxLifecycle, - build_firewall_config, build_network_config, get_auto_resume_enabled, McpServer, @@ -174,7 +172,6 @@ async def _create_sandbox( secure: bool, mcp: Optional[McpServer] = None, network: Optional[SandboxNetworkOpts] = None, - firewall: Optional[SandboxFirewall] = None, lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[List[SandboxVolumeMountAPI]] = None, **opts: Unpack[ApiParams], @@ -185,8 +182,7 @@ async def _create_sandbox( lifecycle["on_timeout"] == "pause" if lifecycle is not None else auto_pause ) auto_resume_enabled = get_auto_resume_enabled(lifecycle) - network_body = build_network_config(network, firewall) - firewall_body = build_firewall_config(firewall) + network_body = build_network_config(network) body = NewSandbox( template_id=template, auto_pause=(should_auto_pause if should_auto_pause is not None else UNSET), @@ -197,7 +193,6 @@ async def _create_sandbox( secure=secure, allow_internet_access=allow_internet_access, network=SandboxNetworkConfig(**network_body) if network_body else UNSET, - firewall=firewall_body if firewall_body is not None else UNSET, volume_mounts=volume_mounts if volume_mounts else UNSET, ) if auto_resume_enabled is not None: diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index 3fc70995fb..38f24bf3cd 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -21,7 +21,6 @@ from e2b.sandbox.main import SandboxOpts from e2b.sandbox.sandbox_api import ( McpServer, - SandboxFirewall, SandboxLifecycle, SandboxMetrics, SandboxNetworkOpts, @@ -176,7 +175,6 @@ def create( allow_internet_access: bool = True, mcp: Optional[McpServer] = None, network: Optional[SandboxNetworkOpts] = None, - firewall: Optional[SandboxFirewall] = None, lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[SandboxVolumeMount] = None, **opts: Unpack[ApiParams], @@ -193,8 +191,7 @@ def create( :param secure: Envd is secured with access token and cannot be used without it, defaults to `True`. :param allow_internet_access: Allow sandbox to access the internet, defaults to `True`. If set to `False`, it works the same as setting network `deny_out` to `[0.0.0.0/0]`. :param mcp: MCP server to enable in the sandbox - :param network: Sandbox network configuration. ``allow_out``/``deny_out`` may also be a callable receiving a :class:`SandboxNetworkSelectorContext` (``ctx.firewall_hosts``, ``ctx.all_hosts``) and returning a list of strings. - :param firewall: Per-host firewall rules applied to outbound requests. Hosts registered here are exposed to the network ``allow_out``/``deny_out`` callables via ``firewall_hosts`` but are not allowed by default — they must also appear in ``network.allow_out``. + :param network: Sandbox network configuration. ``allow_out``/``deny_out`` may also be a callable receiving a :class:`SandboxNetworkSelectorContext` (``ctx.all_traffic``, ``ctx.rules``) and returning a list of strings. Per-host transform rules are nested under ``network.rules``. :param lifecycle: Sandbox lifecycle configuration — ``on_timeout``: ``"kill"`` (default) or ``"pause"``; ``auto_resume``: ``False`` (default) or ``True`` (only when ``on_timeout="pause"``). Example: ``{"on_timeout": "pause", "auto_resume": True}`` :param volume_mounts: Dictionary mapping mount paths to Volume instances or volume names @@ -227,7 +224,6 @@ def create( allow_internet_access=allow_internet_access, mcp=mcp, network=network, - firewall=firewall, lifecycle=lifecycle, volume_mounts=transformed_mounts, **opts, @@ -891,7 +887,6 @@ def _create( allow_internet_access: bool, mcp: Optional[McpServer] = None, network: Optional[SandboxNetworkOpts] = None, - firewall: Optional[SandboxFirewall] = None, lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[list] = None, **opts: Unpack[ApiParams], @@ -916,7 +911,6 @@ def _create( allow_internet_access=allow_internet_access, mcp=mcp, network=network, - firewall=firewall, lifecycle=lifecycle, volume_mounts=volume_mounts, **opts, diff --git a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py index f5532229ba..7a9d914a25 100644 --- a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py @@ -36,9 +36,7 @@ ) from e2b.sandbox.main import SandboxBase from e2b.sandbox.sandbox_api import ( - SandboxFirewall, SandboxLifecycle, - build_firewall_config, build_network_config, get_auto_resume_enabled, McpServer, @@ -173,7 +171,6 @@ def _create_sandbox( secure: bool, mcp: Optional[McpServer] = None, network: Optional[SandboxNetworkOpts] = None, - firewall: Optional[SandboxFirewall] = None, lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[List[SandboxVolumeMountAPI]] = None, **opts: Unpack[ApiParams], @@ -184,8 +181,7 @@ def _create_sandbox( lifecycle["on_timeout"] == "pause" if lifecycle is not None else auto_pause ) auto_resume_enabled = get_auto_resume_enabled(lifecycle) - network_body = build_network_config(network, firewall) - firewall_body = build_firewall_config(firewall) + network_body = build_network_config(network) body = NewSandbox( template_id=template, auto_pause=(should_auto_pause if should_auto_pause is not None else UNSET), @@ -196,7 +192,6 @@ def _create_sandbox( secure=secure, allow_internet_access=allow_internet_access, network=SandboxNetworkConfig(**network_body) if network_body else UNSET, - firewall=firewall_body if firewall_body is not None else UNSET, volume_mounts=volume_mounts if volume_mounts else UNSET, ) if auto_resume_enabled is not None: diff --git a/packages/python-sdk/tests/async/sandbox_async/test_network.py b/packages/python-sdk/tests/async/sandbox_async/test_network.py index 3ec0be6fbe..c41e214889 100644 --- a/packages/python-sdk/tests/async/sandbox_async/test_network.py +++ b/packages/python-sdk/tests/async/sandbox_async/test_network.py @@ -11,7 +11,7 @@ async def test_allow_specific_ip_with_deny_all(async_sandbox_factory): """Test that sandbox with denyOut all and allowOut creates a whitelist.""" async_sandbox = await async_sandbox_factory( network=SandboxNetworkOpts( - deny_out=lambda ctx: ctx.all_hosts, allow_out=["1.1.1.1"] + deny_out=lambda ctx: [ctx.all_traffic], allow_out=["1.1.1.1"] ) ) @@ -54,9 +54,9 @@ async def test_deny_specific_ip(async_sandbox_factory): @pytest.mark.skip_debug() async def test_deny_all_traffic(async_sandbox_factory): - """Test that sandbox can deny all traffic using the all_hosts selector.""" + """Test that sandbox can deny all traffic using the all_traffic selector.""" async_sandbox = await async_sandbox_factory( - network=SandboxNetworkOpts(deny_out=lambda ctx: ctx.all_hosts), timeout=30 + network=SandboxNetworkOpts(deny_out=lambda ctx: [ctx.all_traffic]), timeout=30 ) # Test that all traffic is denied @@ -78,7 +78,7 @@ async def test_allow_takes_precedence_over_deny(async_sandbox_factory): """Test that allowOut takes precedence over denyOut.""" async_sandbox = await async_sandbox_factory( network=SandboxNetworkOpts( - deny_out=lambda ctx: ctx.all_hosts, allow_out=["1.1.1.1", "8.8.8.8"] + deny_out=lambda ctx: [ctx.all_traffic], allow_out=["1.1.1.1", "8.8.8.8"] ) ) @@ -170,14 +170,14 @@ async def test_firewall_transform_injects_headers(async_sandbox_factory): injected_value = "e2b-transform-value-123" network: SandboxNetworkOpts = { - "allow_out": lambda ctx: ctx.firewall_hosts, + "allow_out": lambda ctx: list(ctx.rules.keys()), + "rules": { + "httpbin.org": [ + {"transform": {"headers": {injected_header: injected_value}}}, + ], + }, } - firewall = { - "httpbin.org": [ - {"transform": {"headers": {injected_header: injected_value}}}, - ], - } - async_sandbox = await async_sandbox_factory(network=network, firewall=firewall) + async_sandbox = await async_sandbox_factory(network=network) result = await async_sandbox.commands.run( "curl -sS --max-time 10 https://httpbin.org/headers" diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_network.py b/packages/python-sdk/tests/sync/sandbox_sync/test_network.py index 26280134ca..689553e159 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_network.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_network.py @@ -11,7 +11,7 @@ def test_allow_specific_ip_with_deny_all(sandbox_factory): """Test that sandbox with denyOut all and allowOut creates a whitelist.""" sandbox = sandbox_factory( network=SandboxNetworkOpts( - deny_out=lambda ctx: ctx.all_hosts, allow_out=["1.1.1.1"] + deny_out=lambda ctx: [ctx.all_traffic], allow_out=["1.1.1.1"] ) ) @@ -52,9 +52,9 @@ def test_deny_specific_ip(sandbox_factory): @pytest.mark.skip_debug() def test_deny_all_traffic(sandbox_factory): - """Test that sandbox can deny all traffic using the all_hosts selector.""" + """Test that sandbox can deny all traffic using the all_traffic selector.""" sandbox = sandbox_factory( - network=SandboxNetworkOpts(deny_out=lambda ctx: ctx.all_hosts), timeout=30 + network=SandboxNetworkOpts(deny_out=lambda ctx: [ctx.all_traffic]), timeout=30 ) # Test that all traffic is denied @@ -76,7 +76,7 @@ def test_allow_takes_precedence_over_deny(sandbox_factory): """Test that allowOut takes precedence over denyOut.""" sandbox = sandbox_factory( network=SandboxNetworkOpts( - deny_out=lambda ctx: ctx.all_hosts, allow_out=["1.1.1.1", "8.8.8.8"] + deny_out=lambda ctx: [ctx.all_traffic], allow_out=["1.1.1.1", "8.8.8.8"] ) ) @@ -168,14 +168,14 @@ def test_firewall_transform_injects_headers(sandbox_factory): injected_value = "e2b-transform-value-123" network: SandboxNetworkOpts = { - "allow_out": lambda ctx: ctx.firewall_hosts, + "allow_out": lambda ctx: list(ctx.rules.keys()), + "rules": { + "httpbin.org": [ + {"transform": {"headers": {injected_header: injected_value}}}, + ], + }, } - firewall = { - "httpbin.org": [ - {"transform": {"headers": {injected_header: injected_value}}}, - ], - } - sandbox = sandbox_factory(network=network, firewall=firewall) + sandbox = sandbox_factory(network=network) result = sandbox.commands.run("curl -sS --max-time 10 https://httpbin.org/headers") assert result.exit_code == 0 diff --git a/spec/openapi.yml b/spec/openapi.yml index 7d3664164a..898574b630 100644 --- a/spec/openapi.yml +++ b/spec/openapi.yml @@ -115,42 +115,42 @@ components: type: string responses: - '400': + "400": description: Bad request content: application/json: schema: - $ref: '#/components/schemas/Error' - '401': + $ref: "#/components/schemas/Error" + "401": description: Authentication error content: application/json: schema: - $ref: '#/components/schemas/Error' - '403': + $ref: "#/components/schemas/Error" + "403": description: Forbidden content: application/json: schema: - $ref: '#/components/schemas/Error' - '404': + $ref: "#/components/schemas/Error" + "404": description: Not found content: application/json: schema: - $ref: '#/components/schemas/Error' - '409': + $ref: "#/components/schemas/Error" + "409": description: Conflict content: application/json: schema: - $ref: '#/components/schemas/Error' - '500': + $ref: "#/components/schemas/Error" + "500": description: Server error content: application/json: schema: - $ref: '#/components/schemas/Error' + $ref: "#/components/schemas/Error" schemas: Team: @@ -262,31 +262,6 @@ components: additionalProperties: {} nullable: true - SandboxFirewallRuleTransform: - type: object - description: Transform applied to egress requests matching a firewall rule. - properties: - headers: - type: object - description: Headers to inject into the outbound request. Values override any headers already present. - additionalProperties: - type: string - - SandboxFirewallRule: - type: object - description: Firewall rule applied to outbound requests matching the host it is registered under. - properties: - transform: - $ref: '#/components/schemas/SandboxFirewallRuleTransform' - - SandboxFirewall: - type: object - description: Map of host to ordered list of firewall rules applied to outbound requests for that host. Registering a host here does not allow egress on its own; the host must also appear in network.allowOut. - additionalProperties: - type: array - items: - $ref: '#/components/schemas/SandboxFirewallRule' - SandboxNetworkConfig: type: object properties: @@ -296,17 +271,47 @@ components: description: Specify if the sandbox URLs should be accessible only with authentication. allowOut: type: array - description: List of allowed CIDR blocks, IP addresses, or hostnames for egress traffic. Allowed entries always take precedence over denied ones. + description: List of allowed destinations for egress traffic. Each entry can be a CIDR block (e.g. "8.8.8.8/32"), a bare IP address (e.g. "8.8.8.8"), or a domain name (e.g. "example.com", "*.example.com"). Allowed entries always take precedence over denied entries. items: type: string denyOut: type: array - description: List of denied CIDR blocks, IP addresses, or hostnames for egress traffic. + description: List of denied CIDR blocks or IP addresses for egress traffic. Domain names are not supported for deny rules. items: type: string maskRequestHost: type: string description: Specify host mask which will be used for all sandbox requests + rules: + type: object + description: > + Per-domain transform rules applied to matching egress HTTP/HTTPS requests. + Keys are domains (e.g. "api.example.com", "example.com"). + A domain listed here is not automatically allowed - use allowOut to permit the traffic. + additionalProperties: + type: array + items: + $ref: '#/components/schemas/SandboxNetworkRule' + + SandboxNetworkRule: + type: object + description: Transform rule applied to egress requests matching a domain pattern. + properties: + transform: + $ref: '#/components/schemas/SandboxNetworkTransform' + + SandboxNetworkTransform: + type: object + description: Transformations applied to matching egress requests before forwarding. + properties: + headers: + type: object + description: > + HTTP headers to inject or override in matching requests. + An existing header with the same name is replaced. Values are plain strings; + secret resolution happens client-side before sending to the API. + additionalProperties: + type: string SandboxAutoResumeEnabled: type: boolean @@ -371,7 +376,7 @@ components: type: string description: Log message content level: - $ref: '#/components/schemas/LogLevel' + $ref: "#/components/schemas/LogLevel" fields: type: object additionalProperties: @@ -386,12 +391,12 @@ components: description: Logs of the sandbox type: array items: - $ref: '#/components/schemas/SandboxLog' + $ref: "#/components/schemas/SandboxLog" logEntries: description: Structured logs of the sandbox type: array items: - $ref: '#/components/schemas/SandboxLogEntry' + $ref: "#/components/schemas/SandboxLogEntry" SandboxLogsV2Response: required: @@ -402,7 +407,7 @@ components: description: Sandbox logs structured type: array items: - $ref: '#/components/schemas/SandboxLogEntry' + $ref: "#/components/schemas/SandboxLogEntry" SandboxMetric: description: Metric entry with timestamp and line @@ -413,6 +418,7 @@ components: - cpuUsedPct - memUsed - memTotal + - memCache - diskUsed - diskTotal properties: @@ -441,6 +447,10 @@ components: type: integer format: int64 description: Total memory in bytes + memCache: + type: integer + format: int64 + description: Cached memory (page cache) in bytes diskUsed: type: integer format: int64 @@ -484,7 +494,7 @@ components: deprecated: true description: Identifier of the client envdVersion: - $ref: '#/components/schemas/EnvdVersion' + $ref: "#/components/schemas/EnvdVersion" envdAccessToken: type: string description: Access token used for envd communication @@ -532,7 +542,7 @@ components: format: date-time description: Time when the sandbox will expire envdVersion: - $ref: '#/components/schemas/EnvdVersion' + $ref: "#/components/schemas/EnvdVersion" envdAccessToken: type: string description: Access token used for envd communication @@ -545,25 +555,23 @@ components: nullable: true description: Base domain where the sandbox traffic is accessible cpuCount: - $ref: '#/components/schemas/CPUCount' + $ref: "#/components/schemas/CPUCount" memoryMB: - $ref: '#/components/schemas/MemoryMB' + $ref: "#/components/schemas/MemoryMB" diskSizeMB: - $ref: '#/components/schemas/DiskSizeMB' + $ref: "#/components/schemas/DiskSizeMB" metadata: - $ref: '#/components/schemas/SandboxMetadata' + $ref: "#/components/schemas/SandboxMetadata" state: - $ref: '#/components/schemas/SandboxState' + $ref: "#/components/schemas/SandboxState" network: - $ref: '#/components/schemas/SandboxNetworkConfig' - firewall: - $ref: '#/components/schemas/SandboxFirewall' + $ref: "#/components/schemas/SandboxNetworkConfig" lifecycle: - $ref: '#/components/schemas/SandboxLifecycle' + $ref: "#/components/schemas/SandboxLifecycle" volumeMounts: type: array items: - $ref: '#/components/schemas/SandboxVolumeMount' + $ref: "#/components/schemas/SandboxVolumeMount" ListedSandbox: required: @@ -600,21 +608,21 @@ components: format: date-time description: Time when the sandbox will expire cpuCount: - $ref: '#/components/schemas/CPUCount' + $ref: "#/components/schemas/CPUCount" memoryMB: - $ref: '#/components/schemas/MemoryMB' + $ref: "#/components/schemas/MemoryMB" diskSizeMB: - $ref: '#/components/schemas/DiskSizeMB' + $ref: "#/components/schemas/DiskSizeMB" metadata: - $ref: '#/components/schemas/SandboxMetadata' + $ref: "#/components/schemas/SandboxMetadata" state: - $ref: '#/components/schemas/SandboxState' + $ref: "#/components/schemas/SandboxState" envdVersion: - $ref: '#/components/schemas/EnvdVersion' + $ref: "#/components/schemas/EnvdVersion" volumeMounts: type: array items: - $ref: '#/components/schemas/SandboxVolumeMount' + $ref: "#/components/schemas/SandboxVolumeMount" SandboxesWithMetrics: required: @@ -622,7 +630,7 @@ components: properties: sandboxes: additionalProperties: - $ref: '#/components/schemas/SandboxMetric' + $ref: "#/components/schemas/SandboxMetric" NewSandbox: required: @@ -642,7 +650,7 @@ components: default: false description: Automatically pauses the sandbox after the timeout autoResume: - $ref: '#/components/schemas/SandboxAutoResumeConfig' + $ref: "#/components/schemas/SandboxAutoResumeConfig" secure: type: boolean description: Secure all system communication with sandbox @@ -652,19 +660,17 @@ components: Allow sandbox to access the internet. When set to false, it behaves the same as specifying denyOut to 0.0.0.0/0 in the network config. network: - $ref: '#/components/schemas/SandboxNetworkConfig' - firewall: - $ref: '#/components/schemas/SandboxFirewall' + $ref: "#/components/schemas/SandboxNetworkConfig" metadata: - $ref: '#/components/schemas/SandboxMetadata' + $ref: "#/components/schemas/SandboxMetadata" envVars: - $ref: '#/components/schemas/EnvVars' + $ref: "#/components/schemas/EnvVars" mcp: - $ref: '#/components/schemas/Mcp' + $ref: "#/components/schemas/Mcp" volumeMounts: type: array items: - $ref: '#/components/schemas/SandboxVolumeMount' + $ref: "#/components/schemas/SandboxVolumeMount" ResumedSandbox: properties: @@ -794,11 +800,11 @@ components: type: string description: Identifier of the last successful build for given template cpuCount: - $ref: '#/components/schemas/CPUCount' + $ref: "#/components/schemas/CPUCount" memoryMB: - $ref: '#/components/schemas/MemoryMB' + $ref: "#/components/schemas/MemoryMB" diskSizeMB: - $ref: '#/components/schemas/DiskSizeMB' + $ref: "#/components/schemas/DiskSizeMB" public: type: boolean description: Whether the template is public or only accessible by the team @@ -823,7 +829,7 @@ components: description: Time when the template was last updated createdBy: allOf: - - $ref: '#/components/schemas/TeamUser' + - $ref: "#/components/schemas/TeamUser" nullable: true lastSpawnedAt: type: string @@ -839,9 +845,9 @@ components: format: int32 description: Number of times the template was built envdVersion: - $ref: '#/components/schemas/EnvdVersion' + $ref: "#/components/schemas/EnvdVersion" buildStatus: - $ref: '#/components/schemas/TemplateBuildStatus' + $ref: "#/components/schemas/TemplateBuildStatus" TemplateRequestResponseV3: required: @@ -902,11 +908,11 @@ components: type: string description: Identifier of the last successful build for given template cpuCount: - $ref: '#/components/schemas/CPUCount' + $ref: "#/components/schemas/CPUCount" memoryMB: - $ref: '#/components/schemas/MemoryMB' + $ref: "#/components/schemas/MemoryMB" diskSizeMB: - $ref: '#/components/schemas/DiskSizeMB' + $ref: "#/components/schemas/DiskSizeMB" public: type: boolean description: Whether the template is public or only accessible by the team @@ -925,7 +931,7 @@ components: description: Time when the template was last updated createdBy: allOf: - - $ref: '#/components/schemas/TeamUser' + - $ref: "#/components/schemas/TeamUser" nullable: true lastSpawnedAt: type: string @@ -941,7 +947,7 @@ components: format: int32 description: Number of times the template was built envdVersion: - $ref: '#/components/schemas/EnvdVersion' + $ref: "#/components/schemas/EnvdVersion" TemplateBuild: required: @@ -957,7 +963,7 @@ components: format: uuid description: Identifier of the build status: - $ref: '#/components/schemas/TemplateBuildStatus' + $ref: "#/components/schemas/TemplateBuildStatus" createdAt: type: string format: date-time @@ -971,13 +977,13 @@ components: format: date-time description: Time when the build was finished cpuCount: - $ref: '#/components/schemas/CPUCount' + $ref: "#/components/schemas/CPUCount" memoryMB: - $ref: '#/components/schemas/MemoryMB' + $ref: "#/components/schemas/MemoryMB" diskSizeMB: - $ref: '#/components/schemas/DiskSizeMB' + $ref: "#/components/schemas/DiskSizeMB" envdVersion: - $ref: '#/components/schemas/EnvdVersion' + $ref: "#/components/schemas/EnvdVersion" TemplateWithBuilds: required: @@ -1029,7 +1035,7 @@ components: type: array description: List of builds for the template items: - $ref: '#/components/schemas/TemplateBuild' + $ref: "#/components/schemas/TemplateBuild" TemplateAliasResponse: required: @@ -1063,9 +1069,9 @@ components: description: Ready check command to execute in the template after the build type: string cpuCount: - $ref: '#/components/schemas/CPUCount' + $ref: "#/components/schemas/CPUCount" memoryMB: - $ref: '#/components/schemas/MemoryMB' + $ref: "#/components/schemas/MemoryMB" TemplateStep: description: Step in the template build process @@ -1108,9 +1114,9 @@ components: type: string description: Identifier of the team cpuCount: - $ref: '#/components/schemas/CPUCount' + $ref: "#/components/schemas/CPUCount" memoryMB: - $ref: '#/components/schemas/MemoryMB' + $ref: "#/components/schemas/MemoryMB" TemplateBuildRequestV2: required: @@ -1124,21 +1130,21 @@ components: type: string description: Identifier of the team cpuCount: - $ref: '#/components/schemas/CPUCount' + $ref: "#/components/schemas/CPUCount" memoryMB: - $ref: '#/components/schemas/MemoryMB' + $ref: "#/components/schemas/MemoryMB" FromImageRegistry: oneOf: - - $ref: '#/components/schemas/AWSRegistry' - - $ref: '#/components/schemas/GCPRegistry' - - $ref: '#/components/schemas/GeneralRegistry' + - $ref: "#/components/schemas/AWSRegistry" + - $ref: "#/components/schemas/GCPRegistry" + - $ref: "#/components/schemas/GeneralRegistry" discriminator: propertyName: type mapping: - aws: '#/components/schemas/AWSRegistry' - gcp: '#/components/schemas/GCPRegistry' - registry: '#/components/schemas/GeneralRegistry' + aws: "#/components/schemas/AWSRegistry" + gcp: "#/components/schemas/GCPRegistry" + registry: "#/components/schemas/GeneralRegistry" AWSRegistry: type: object @@ -1204,7 +1210,7 @@ components: type: string description: Template to use as a base for the template build fromImageRegistry: - $ref: '#/components/schemas/FromImageRegistry' + $ref: "#/components/schemas/FromImageRegistry" force: default: false type: boolean @@ -1214,7 +1220,7 @@ components: description: List of steps to execute in the template build type: array items: - $ref: '#/components/schemas/TemplateStep' + $ref: "#/components/schemas/TemplateStep" startCmd: description: Start command to execute in the template after the build type: string @@ -1256,7 +1262,7 @@ components: type: string description: Log message content level: - $ref: '#/components/schemas/LogLevel' + $ref: "#/components/schemas/LogLevel" step: type: string description: Step in the build process related to the log entry @@ -1276,7 +1282,7 @@ components: description: Log entries related to the status reason type: array items: - $ref: '#/components/schemas/BuildLogEntry' + $ref: "#/components/schemas/BuildLogEntry" TemplateBuildStatus: type: string @@ -1306,7 +1312,7 @@ components: description: Build logs structured type: array items: - $ref: '#/components/schemas/BuildLogEntry' + $ref: "#/components/schemas/BuildLogEntry" templateID: type: string description: Identifier of the template @@ -1314,9 +1320,9 @@ components: type: string description: Identifier of the build status: - $ref: '#/components/schemas/TemplateBuildStatus' + $ref: "#/components/schemas/TemplateBuildStatus" reason: - $ref: '#/components/schemas/BuildStatusReason' + $ref: "#/components/schemas/BuildStatusReason" TemplateBuildLogsResponse: required: @@ -1327,7 +1333,7 @@ components: description: Build logs structured type: array items: - $ref: '#/components/schemas/BuildLogEntry' + $ref: "#/components/schemas/BuildLogEntry" LogsDirection: type: string @@ -1351,17 +1357,22 @@ components: NodeStatus: type: string - description: Status of the node + description: | + Status of the node. + - draining: the node is bound to be shut down. It will not accept new sandboxes and will stop once all existing sandboxes are done. + - standby: the node is not actively used, but it can return to ready and continue serving traffic. enum: - ready - draining - connecting - unhealthy + - standby x-enum-varnames: - NodeStatusReady - NodeStatusDraining - NodeStatusConnecting - NodeStatusUnhealthy + - NodeStatusStandby NodeStatusChange: required: @@ -1372,7 +1383,7 @@ components: format: uuid description: Identifier of the cluster status: - $ref: '#/components/schemas/NodeStatus' + $ref: "#/components/schemas/NodeStatus" DiskMetrics: required: @@ -1439,7 +1450,7 @@ components: type: array description: Detailed metrics for each disk/mount point items: - $ref: '#/components/schemas/DiskMetrics' + $ref: "#/components/schemas/DiskMetrics" MachineInfo: required: - cpuFamily @@ -1491,15 +1502,15 @@ components: type: string description: Identifier of the cluster machineInfo: - $ref: '#/components/schemas/MachineInfo' + $ref: "#/components/schemas/MachineInfo" status: - $ref: '#/components/schemas/NodeStatus' + $ref: "#/components/schemas/NodeStatus" sandboxCount: type: integer format: uint32 description: Number of sandboxes running on the node metrics: - $ref: '#/components/schemas/NodeMetrics' + $ref: "#/components/schemas/NodeMetrics" createSuccesses: type: integer format: uint64 @@ -1544,15 +1555,15 @@ components: type: string description: Service instance identifier of the node machineInfo: - $ref: '#/components/schemas/MachineInfo' + $ref: "#/components/schemas/MachineInfo" status: - $ref: '#/components/schemas/NodeStatus' + $ref: "#/components/schemas/NodeStatus" sandboxCount: type: integer format: uint32 description: Number of sandboxes running on the node metrics: - $ref: '#/components/schemas/NodeMetrics' + $ref: "#/components/schemas/NodeMetrics" cachedBuilds: type: array description: List of cached builds id on the node @@ -1586,7 +1597,7 @@ components: type: string description: The fully created access token mask: - $ref: '#/components/schemas/IdentifierMaskingDetails' + $ref: "#/components/schemas/IdentifierMaskingDetails" createdAt: type: string format: date-time @@ -1615,14 +1626,14 @@ components: type: string description: Name of the API key mask: - $ref: '#/components/schemas/IdentifierMaskingDetails' + $ref: "#/components/schemas/IdentifierMaskingDetails" createdAt: type: string format: date-time description: Timestamp of API key creation createdBy: allOf: - - $ref: '#/components/schemas/TeamUser' + - $ref: "#/components/schemas/TeamUser" nullable: true lastUsed: type: string @@ -1646,7 +1657,7 @@ components: type: string description: Raw value of the API key mask: - $ref: '#/components/schemas/IdentifierMaskingDetails' + $ref: "#/components/schemas/IdentifierMaskingDetails" name: type: string description: Name of the API key @@ -1656,7 +1667,7 @@ components: description: Timestamp of API key creation createdBy: allOf: - - $ref: '#/components/schemas/TeamUser' + - $ref: "#/components/schemas/TeamUser" nullable: true lastUsed: type: string @@ -1810,7 +1821,7 @@ components: name: type: string description: Name of the volume - pattern: '^[a-zA-Z0-9_-]+$' + pattern: "^[a-zA-Z0-9_-]+$" required: - name @@ -1828,10 +1839,10 @@ paths: get: description: Health check responses: - '200': - description: Request was successful - '401': - $ref: '#/components/responses/401' + "204": + description: The service is healthy + "401": + $ref: "#/components/responses/401" /teams: get: @@ -1841,7 +1852,7 @@ paths: - AccessTokenAuth: [] - Supabase1TokenAuth: [] responses: - '200': + "200": description: Successfully returned all teams content: application/json: @@ -1849,11 +1860,11 @@ paths: type: array items: allOf: - - $ref: '#/components/schemas/Team' - '401': - $ref: '#/components/responses/401' - '500': - $ref: '#/components/responses/500' + - $ref: "#/components/schemas/Team" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" /teams/{teamID}/metrics: get: @@ -1864,7 +1875,7 @@ paths: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] parameters: - - $ref: '#/components/parameters/teamID' + - $ref: "#/components/parameters/teamID" - in: query name: start schema: @@ -1880,22 +1891,22 @@ paths: minimum: 0 description: Unix timestamp for the end of the interval, in seconds, for which the metrics responses: - '200': + "200": description: Successfully returned the team metrics content: application/json: schema: type: array items: - $ref: '#/components/schemas/TeamMetric' - '400': - $ref: '#/components/responses/400' - '401': - $ref: '#/components/responses/401' - '403': - $ref: '#/components/responses/403' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/TeamMetric" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "403": + $ref: "#/components/responses/403" + "500": + $ref: "#/components/responses/500" /teams/{teamID}/metrics/max: get: @@ -1906,7 +1917,7 @@ paths: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] parameters: - - $ref: '#/components/parameters/teamID' + - $ref: "#/components/parameters/teamID" - in: query name: start schema: @@ -1929,20 +1940,20 @@ paths: enum: [concurrent_sandboxes, sandbox_start_rate] description: Metric to retrieve the maximum value for responses: - '200': + "200": description: Successfully returned the team metrics content: application/json: schema: - $ref: '#/components/schemas/MaxTeamMetric' - '400': - $ref: '#/components/responses/400' - '401': - $ref: '#/components/responses/401' - '403': - $ref: '#/components/responses/403' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/MaxTeamMetric" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "403": + $ref: "#/components/responses/403" + "500": + $ref: "#/components/responses/500" /sandboxes: get: @@ -1960,7 +1971,7 @@ paths: schema: type: string responses: - '200': + "200": description: Successfully returned all running sandboxes content: application/json: @@ -1968,13 +1979,13 @@ paths: type: array items: allOf: - - $ref: '#/components/schemas/ListedSandbox' - '401': - $ref: '#/components/responses/401' - '400': - $ref: '#/components/responses/400' - '500': - $ref: '#/components/responses/500' + - $ref: "#/components/schemas/ListedSandbox" + "401": + $ref: "#/components/responses/401" + "400": + $ref: "#/components/responses/400" + "500": + $ref: "#/components/responses/500" post: description: Create a sandbox from the template tags: [sandboxes] @@ -1987,20 +1998,20 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/NewSandbox' + $ref: "#/components/schemas/NewSandbox" responses: - '201': + "201": description: The sandbox was created successfully content: application/json: schema: - $ref: '#/components/schemas/Sandbox' - '401': - $ref: '#/components/responses/401' - '400': - $ref: '#/components/responses/400' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/Sandbox" + "401": + $ref: "#/components/responses/401" + "400": + $ref: "#/components/responses/400" + "500": + $ref: "#/components/responses/500" /v2/sandboxes: get: @@ -2024,13 +2035,13 @@ paths: schema: type: array items: - $ref: '#/components/schemas/SandboxState' + $ref: "#/components/schemas/SandboxState" style: form explode: false - - $ref: '#/components/parameters/paginationNextToken' - - $ref: '#/components/parameters/paginationLimit' + - $ref: "#/components/parameters/paginationNextToken" + - $ref: "#/components/parameters/paginationLimit" responses: - '200': + "200": description: Successfully returned all running sandboxes content: application/json: @@ -2038,13 +2049,13 @@ paths: type: array items: allOf: - - $ref: '#/components/schemas/ListedSandbox' - '401': - $ref: '#/components/responses/401' - '400': - $ref: '#/components/responses/400' - '500': - $ref: '#/components/responses/500' + - $ref: "#/components/schemas/ListedSandbox" + "401": + $ref: "#/components/responses/401" + "400": + $ref: "#/components/responses/400" + "500": + $ref: "#/components/responses/500" /sandboxes/metrics: get: @@ -2067,18 +2078,18 @@ paths: maxItems: 100 uniqueItems: true responses: - '200': + "200": description: Successfully returned all running sandboxes with metrics content: application/json: schema: - $ref: '#/components/schemas/SandboxesWithMetrics' - '401': - $ref: '#/components/responses/401' - '400': - $ref: '#/components/responses/400' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/SandboxesWithMetrics" + "401": + $ref: "#/components/responses/401" + "400": + $ref: "#/components/responses/400" + "500": + $ref: "#/components/responses/500" /sandboxes/{sandboxID}/logs: get: @@ -2090,7 +2101,7 @@ paths: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] parameters: - - $ref: '#/components/parameters/sandboxID' + - $ref: "#/components/parameters/sandboxID" - in: query name: start schema: @@ -2107,18 +2118,18 @@ paths: type: integer description: Maximum number of logs that should be returned responses: - '200': + "200": description: Successfully returned the sandbox logs content: application/json: schema: - $ref: '#/components/schemas/SandboxLogs' - '404': - $ref: '#/components/responses/404' - '401': - $ref: '#/components/responses/401' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/SandboxLogs" + "404": + $ref: "#/components/responses/404" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" /v2/sandboxes/{sandboxID}/logs: get: @@ -2129,7 +2140,7 @@ paths: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] parameters: - - $ref: '#/components/parameters/sandboxID' + - $ref: "#/components/parameters/sandboxID" - in: query name: cursor schema: @@ -2149,12 +2160,12 @@ paths: - in: query name: direction schema: - $ref: '#/components/schemas/LogsDirection' + $ref: "#/components/schemas/LogsDirection" description: Direction of the logs that should be returned - in: query name: level schema: - $ref: '#/components/schemas/LogLevel' + $ref: "#/components/schemas/LogLevel" description: Minimum log level to return. Logs below this level are excluded - in: query name: search @@ -2163,18 +2174,18 @@ paths: maxLength: 256 description: Case-sensitive substring match on log message content responses: - '200': + "200": description: Successfully returned the sandbox logs content: application/json: schema: - $ref: '#/components/schemas/SandboxLogsV2Response' - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/SandboxLogsV2Response" + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" /sandboxes/{sandboxID}: get: @@ -2185,20 +2196,20 @@ paths: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] parameters: - - $ref: '#/components/parameters/sandboxID' + - $ref: "#/components/parameters/sandboxID" responses: - '200': + "200": description: Successfully returned the sandbox content: application/json: schema: - $ref: '#/components/schemas/SandboxDetail' - '404': - $ref: '#/components/responses/404' - '401': - $ref: '#/components/responses/401' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/SandboxDetail" + "404": + $ref: "#/components/responses/404" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" delete: description: Kill a sandbox @@ -2208,16 +2219,16 @@ paths: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] parameters: - - $ref: '#/components/parameters/sandboxID' + - $ref: "#/components/parameters/sandboxID" responses: - '204': + "204": description: The sandbox was killed successfully - '404': - $ref: '#/components/responses/404' - '401': - $ref: '#/components/responses/401' - '500': - $ref: '#/components/responses/500' + "404": + $ref: "#/components/responses/404" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" /sandboxes/{sandboxID}/metrics: get: @@ -2228,7 +2239,7 @@ paths: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] parameters: - - $ref: '#/components/parameters/sandboxID' + - $ref: "#/components/parameters/sandboxID" - in: query name: start schema: @@ -2245,22 +2256,22 @@ paths: description: Unix timestamp for the end of the interval, in seconds, for which the metrics responses: - '200': + "200": description: Successfully returned the sandbox metrics content: application/json: schema: type: array items: - $ref: '#/components/schemas/SandboxMetric' - '400': - $ref: '#/components/responses/400' - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/SandboxMetric" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" # TODO: Pause and resume might be exposed as POST /sandboxes/{sandboxID}/snapshot and then POST /sandboxes with specified snapshotting setup /sandboxes/{sandboxID}/pause: @@ -2272,18 +2283,18 @@ paths: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] parameters: - - $ref: '#/components/parameters/sandboxID' + - $ref: "#/components/parameters/sandboxID" responses: - '204': + "204": description: The sandbox was paused successfully and can be resumed - '409': - $ref: '#/components/responses/409' - '404': - $ref: '#/components/responses/404' - '401': - $ref: '#/components/responses/401' - '500': - $ref: '#/components/responses/500' + "409": + $ref: "#/components/responses/409" + "404": + $ref: "#/components/responses/404" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" /sandboxes/{sandboxID}/resume: post: @@ -2295,28 +2306,28 @@ paths: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] parameters: - - $ref: '#/components/parameters/sandboxID' + - $ref: "#/components/parameters/sandboxID" requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/ResumedSandbox' + $ref: "#/components/schemas/ResumedSandbox" responses: - '201': + "201": description: The sandbox was resumed successfully content: application/json: schema: - $ref: '#/components/schemas/Sandbox' - '409': - $ref: '#/components/responses/409' - '404': - $ref: '#/components/responses/404' - '401': - $ref: '#/components/responses/401' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/Sandbox" + "409": + $ref: "#/components/responses/409" + "404": + $ref: "#/components/responses/404" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" /sandboxes/{sandboxID}/connect: post: @@ -2327,34 +2338,34 @@ paths: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] parameters: - - $ref: '#/components/parameters/sandboxID' + - $ref: "#/components/parameters/sandboxID" requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/ConnectSandbox' + $ref: "#/components/schemas/ConnectSandbox" responses: - '200': + "200": description: The sandbox was already running content: application/json: schema: - $ref: '#/components/schemas/Sandbox' - '201': + $ref: "#/components/schemas/Sandbox" + "201": description: The sandbox was resumed successfully content: application/json: schema: - $ref: '#/components/schemas/Sandbox' - '400': - $ref: '#/components/responses/400' - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/Sandbox" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" /sandboxes/{sandboxID}/timeout: post: @@ -2378,16 +2389,67 @@ paths: format: int32 minimum: 0 parameters: - - $ref: '#/components/parameters/sandboxID' + - $ref: "#/components/parameters/sandboxID" responses: - '204': + "204": description: Successfully set the sandbox timeout - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - '500': - $ref: '#/components/responses/500' + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" + + /sandboxes/{sandboxID}/network: + put: + description: Update the network configuration for a running sandbox. Replaces the current egress rules with the provided configuration. Omitting both fields clears all egress rules. + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + tags: [sandboxes] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + allowOut: + type: array + description: List of allowed destinations for egress traffic. Each entry can be a CIDR block (e.g. "8.8.8.8/32"), a bare IP address (e.g. "8.8.8.8"), or a domain name (e.g. "example.com", "*.example.com"). Allowed entries always take precedence over denied entries. + items: + type: string + denyOut: + type: array + description: List of denied CIDR blocks or IP addresses for egress traffic. Domain names are not supported for deny rules. + items: + type: string + rules: + type: object + description: Per-domain transform rules. Replaces all existing rules when provided. + additionalProperties: + type: array + items: + $ref: '#/components/schemas/SandboxNetworkRule' + allow_internet_access: + type: boolean + description: + Allow sandbox to access the internet. When set to false, it behaves the same as specifying denyOut + to 0.0.0.0/0 in the network config. + parameters: + - $ref: "#/components/parameters/sandboxID" + responses: + "204": + description: Successfully updated the sandbox network configuration + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "409": + $ref: "#/components/responses/409" + "500": + $ref: "#/components/responses/500" /sandboxes/{sandboxID}/refreshes: post: @@ -2409,14 +2471,14 @@ paths: maximum: 3600 # 1 hour minimum: 0 parameters: - - $ref: '#/components/parameters/sandboxID' + - $ref: "#/components/parameters/sandboxID" responses: - '204': + "204": description: Successfully refreshed the sandbox - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" /sandboxes/{sandboxID}/snapshots: post: @@ -2427,7 +2489,7 @@ paths: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] parameters: - - $ref: '#/components/parameters/sandboxID' + - $ref: "#/components/parameters/sandboxID" requestBody: required: true content: @@ -2439,20 +2501,20 @@ paths: type: string description: Optional name for the snapshot template. If a snapshot template with this name already exists, a new build will be assigned to the existing template instead of creating a new one. responses: - '201': + "201": description: Snapshot created successfully content: application/json: schema: - $ref: '#/components/schemas/SnapshotInfo' - '400': - $ref: '#/components/responses/400' - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/SnapshotInfo" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" /snapshots: get: @@ -2469,21 +2531,21 @@ paths: schema: type: string description: Filter snapshots by source sandbox ID - - $ref: '#/components/parameters/paginationLimit' - - $ref: '#/components/parameters/paginationNextToken' + - $ref: "#/components/parameters/paginationLimit" + - $ref: "#/components/parameters/paginationNextToken" responses: - '200': + "200": description: Successfully returned snapshots content: application/json: schema: type: array items: - $ref: '#/components/schemas/SnapshotInfo' - '401': - $ref: '#/components/responses/401' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/SnapshotInfo" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" /v3/templates: post: @@ -2498,21 +2560,21 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/TemplateBuildRequestV3' + $ref: "#/components/schemas/TemplateBuildRequestV3" responses: - '202': + "202": description: The build was requested successfully content: application/json: schema: - $ref: '#/components/schemas/TemplateRequestResponseV3' - '400': - $ref: '#/components/responses/400' - '401': - $ref: '#/components/responses/401' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/TemplateRequestResponseV3" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" /v2/templates: post: @@ -2528,21 +2590,21 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/TemplateBuildRequestV2' + $ref: "#/components/schemas/TemplateBuildRequestV2" responses: - '202': + "202": description: The build was requested successfully content: application/json: schema: - $ref: '#/components/schemas/TemplateLegacy' - '400': - $ref: '#/components/responses/400' - '401': - $ref: '#/components/responses/401' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/TemplateLegacy" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" /templates/{templateID}/files/{hash}: get: @@ -2554,7 +2616,7 @@ paths: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] parameters: - - $ref: '#/components/parameters/templateID' + - $ref: "#/components/parameters/templateID" - in: path name: hash required: true @@ -2563,20 +2625,20 @@ paths: description: Hash of the files responses: - '201': + "201": description: The upload link where to upload the tar file content: application/json: schema: - $ref: '#/components/schemas/TemplateBuildFileUpload' - '400': - $ref: '#/components/responses/400' - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/TemplateBuildFileUpload" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" /templates: get: @@ -2595,7 +2657,7 @@ paths: type: string description: Identifier of the team responses: - '200': + "200": description: Successfully returned all templates content: application/json: @@ -2603,11 +2665,11 @@ paths: type: array items: allOf: - - $ref: '#/components/schemas/Template' - '401': - $ref: '#/components/responses/401' - '500': - $ref: '#/components/responses/500' + - $ref: "#/components/schemas/Template" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" post: description: Create a new template deprecated: true @@ -2621,21 +2683,21 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/TemplateBuildRequest' + $ref: "#/components/schemas/TemplateBuildRequest" responses: - '202': + "202": description: The build was accepted content: application/json: schema: - $ref: '#/components/schemas/TemplateLegacy' - '400': - $ref: '#/components/responses/400' - '401': - $ref: '#/components/responses/401' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/TemplateLegacy" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" /templates/{templateID}: get: @@ -2646,20 +2708,20 @@ paths: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] parameters: - - $ref: '#/components/parameters/templateID' - - $ref: '#/components/parameters/paginationNextToken' - - $ref: '#/components/parameters/paginationLimit' + - $ref: "#/components/parameters/templateID" + - $ref: "#/components/parameters/paginationNextToken" + - $ref: "#/components/parameters/paginationLimit" responses: - '200': + "200": description: Successfully returned the template with its builds content: application/json: schema: - $ref: '#/components/schemas/TemplateWithBuilds' - '401': - $ref: '#/components/responses/401' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/TemplateWithBuilds" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" post: description: Rebuild an template deprecated: true @@ -2669,25 +2731,25 @@ paths: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] parameters: - - $ref: '#/components/parameters/templateID' + - $ref: "#/components/parameters/templateID" requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/TemplateBuildRequest' + $ref: "#/components/schemas/TemplateBuildRequest" responses: - '202': + "202": description: The build was accepted content: application/json: schema: - $ref: '#/components/schemas/TemplateLegacy' - '401': - $ref: '#/components/responses/401' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/TemplateLegacy" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" delete: description: Delete a template tags: [templates] @@ -2697,14 +2759,14 @@ paths: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] parameters: - - $ref: '#/components/parameters/templateID' + - $ref: "#/components/parameters/templateID" responses: - '204': + "204": description: The template was deleted successfully - '401': - $ref: '#/components/responses/401' - '500': - $ref: '#/components/responses/500' + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" patch: description: Update template deprecated: true @@ -2715,22 +2777,22 @@ paths: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] parameters: - - $ref: '#/components/parameters/templateID' + - $ref: "#/components/parameters/templateID" requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/TemplateUpdateRequest' + $ref: "#/components/schemas/TemplateUpdateRequest" responses: - '200': + "200": description: The template was updated successfully - '400': - $ref: '#/components/responses/400' - '401': - $ref: '#/components/responses/401' - '500': - $ref: '#/components/responses/500' + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" /templates/{templateID}/builds/{buildID}: post: @@ -2742,15 +2804,15 @@ paths: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] parameters: - - $ref: '#/components/parameters/templateID' - - $ref: '#/components/parameters/buildID' + - $ref: "#/components/parameters/templateID" + - $ref: "#/components/parameters/buildID" responses: - '202': + "202": description: The build has started - '401': - $ref: '#/components/responses/401' - '500': - $ref: '#/components/responses/500' + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" /v2/templates/{templateID}/builds/{buildID}: post: @@ -2761,21 +2823,21 @@ paths: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] parameters: - - $ref: '#/components/parameters/templateID' - - $ref: '#/components/parameters/buildID' + - $ref: "#/components/parameters/templateID" + - $ref: "#/components/parameters/buildID" requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/TemplateBuildStartV2' + $ref: "#/components/schemas/TemplateBuildStartV2" responses: - '202': + "202": description: The build has started - '401': - $ref: '#/components/responses/401' - '500': - $ref: '#/components/responses/500' + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" /v2/templates/{templateID}: patch: @@ -2787,26 +2849,26 @@ paths: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] parameters: - - $ref: '#/components/parameters/templateID' + - $ref: "#/components/parameters/templateID" requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/TemplateUpdateRequest' + $ref: "#/components/schemas/TemplateUpdateRequest" responses: - '200': + "200": description: The template was updated successfully content: application/json: schema: - $ref: '#/components/schemas/TemplateUpdateResponse' - '400': - $ref: '#/components/responses/400' - '401': - $ref: '#/components/responses/401' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/TemplateUpdateResponse" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" /templates/{templateID}/builds/{buildID}/status: get: @@ -2818,8 +2880,8 @@ paths: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] parameters: - - $ref: '#/components/parameters/templateID' - - $ref: '#/components/parameters/buildID' + - $ref: "#/components/parameters/templateID" + - $ref: "#/components/parameters/buildID" - in: query name: logsOffset schema: @@ -2840,20 +2902,20 @@ paths: - in: query name: level schema: - $ref: '#/components/schemas/LogLevel' + $ref: "#/components/schemas/LogLevel" responses: - '200': + "200": description: Successfully returned the template content: application/json: schema: - $ref: '#/components/schemas/TemplateBuildInfo' - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/TemplateBuildInfo" + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" /templates/{templateID}/builds/{buildID}/logs: get: @@ -2865,8 +2927,8 @@ paths: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] parameters: - - $ref: '#/components/parameters/templateID' - - $ref: '#/components/parameters/buildID' + - $ref: "#/components/parameters/templateID" + - $ref: "#/components/parameters/buildID" - in: query name: cursor schema: @@ -2886,29 +2948,29 @@ paths: - in: query name: direction schema: - $ref: '#/components/schemas/LogsDirection' + $ref: "#/components/schemas/LogsDirection" - in: query name: level schema: - $ref: '#/components/schemas/LogLevel' + $ref: "#/components/schemas/LogLevel" - in: query name: source schema: - $ref: '#/components/schemas/LogsSource' + $ref: "#/components/schemas/LogsSource" description: Source of the logs that should be returned from responses: - '200': + "200": description: Successfully returned the template build logs content: application/json: schema: - $ref: '#/components/schemas/TemplateBuildLogsResponse' - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/TemplateBuildLogsResponse" + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" /templates/tags: post: @@ -2923,22 +2985,22 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/AssignTemplateTagsRequest' + $ref: "#/components/schemas/AssignTemplateTagsRequest" responses: - '201': + "201": description: Tag assigned successfully content: application/json: schema: - $ref: '#/components/schemas/AssignedTemplateTags' - '400': - $ref: '#/components/responses/400' - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/AssignedTemplateTags" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" delete: description: Delete multiple tags from templates tags: [tags] @@ -2951,18 +3013,18 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/DeleteTemplateTagsRequest' + $ref: "#/components/schemas/DeleteTemplateTagsRequest" responses: - '204': + "204": description: Tags deleted successfully - '400': - $ref: '#/components/responses/400' - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - '500': - $ref: '#/components/responses/500' + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" /templates/{templateID}/tags: get: @@ -2973,24 +3035,24 @@ paths: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] parameters: - - $ref: '#/components/parameters/templateID' + - $ref: "#/components/parameters/templateID" responses: - '200': + "200": description: Successfully returned the template tags content: application/json: schema: type: array items: - $ref: '#/components/schemas/TemplateTag' - '401': - $ref: '#/components/responses/401' - '403': - $ref: '#/components/responses/403' - '404': - $ref: '#/components/responses/404' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/TemplateTag" + "401": + $ref: "#/components/responses/401" + "403": + $ref: "#/components/responses/403" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" /templates/aliases/{alias}: get: @@ -3008,20 +3070,20 @@ paths: type: string description: Template alias responses: - '200': + "200": description: Successfully queried template by alias content: application/json: schema: - $ref: '#/components/schemas/TemplateAliasResponse' - '400': - $ref: '#/components/responses/400' - '403': - $ref: '#/components/responses/403' - '404': - $ref: '#/components/responses/404' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/TemplateAliasResponse" + "400": + $ref: "#/components/responses/400" + "403": + $ref: "#/components/responses/403" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" /nodes: get: @@ -3030,7 +3092,7 @@ paths: security: - AdminTokenAuth: [] responses: - '200': + "200": description: Successfully returned all nodes content: application/json: @@ -3038,11 +3100,11 @@ paths: type: array items: allOf: - - $ref: '#/components/schemas/Node' - '401': - $ref: '#/components/responses/401' - '500': - $ref: '#/components/responses/500' + - $ref: "#/components/schemas/Node" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" /nodes/{nodeID}: get: @@ -3051,7 +3113,7 @@ paths: security: - AdminTokenAuth: [] parameters: - - $ref: '#/components/parameters/nodeID' + - $ref: "#/components/parameters/nodeID" - in: query name: clusterID description: Identifier of the cluster @@ -3060,39 +3122,39 @@ paths: type: string format: uuid responses: - '200': + "200": description: Successfully returned the node content: application/json: schema: - $ref: '#/components/schemas/NodeDetail' - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/NodeDetail" + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" post: description: Change status of a node tags: [admin] security: - AdminTokenAuth: [] parameters: - - $ref: '#/components/parameters/nodeID' + - $ref: "#/components/parameters/nodeID" requestBody: content: application/json: schema: - $ref: '#/components/schemas/NodeStatusChange' + $ref: "#/components/schemas/NodeStatusChange" responses: - '204': + "204": description: The node status was changed successfully - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - '500': - $ref: '#/components/responses/500' + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" /admin/teams/{teamID}/sandboxes/kill: post: @@ -3110,18 +3172,18 @@ paths: format: uuid description: Team ID responses: - '200': + "200": description: Successfully killed sandboxes content: application/json: schema: - $ref: '#/components/schemas/AdminSandboxKillResult' - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/AdminSandboxKillResult" + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" /admin/teams/{teamID}/builds/cancel: post: @@ -3139,18 +3201,18 @@ paths: format: uuid description: Team ID responses: - '200': + "200": description: Successfully cancelled builds content: application/json: schema: - $ref: '#/components/schemas/AdminBuildCancelResult' - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/AdminBuildCancelResult" + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" /access-tokens: post: @@ -3163,18 +3225,18 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/NewAccessToken' + $ref: "#/components/schemas/NewAccessToken" responses: - '201': + "201": description: Access token created successfully content: application/json: schema: - $ref: '#/components/schemas/CreatedAccessToken' - '401': - $ref: '#/components/responses/401' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/CreatedAccessToken" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" /access-tokens/{accessTokenID}: delete: @@ -3183,16 +3245,16 @@ paths: security: - Supabase1TokenAuth: [] parameters: - - $ref: '#/components/parameters/accessTokenID' + - $ref: "#/components/parameters/accessTokenID" responses: - '204': + "204": description: Access token deleted successfully - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - '500': - $ref: '#/components/responses/500' + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" /api-keys: get: @@ -3202,18 +3264,18 @@ paths: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] responses: - '200': + "200": description: Successfully returned all team API keys content: application/json: schema: type: array items: - $ref: '#/components/schemas/TeamAPIKey' - '401': - $ref: '#/components/responses/401' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/TeamAPIKey" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" post: description: Create a new team API key tags: [api-keys] @@ -3225,18 +3287,18 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/NewTeamAPIKey' + $ref: "#/components/schemas/NewTeamAPIKey" responses: - '201': + "201": description: Team API key created successfully content: application/json: schema: - $ref: '#/components/schemas/CreatedTeamAPIKey' - '401': - $ref: '#/components/responses/401' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/CreatedTeamAPIKey" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" /api-keys/{apiKeyID}: patch: @@ -3246,22 +3308,22 @@ paths: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] parameters: - - $ref: '#/components/parameters/apiKeyID' + - $ref: "#/components/parameters/apiKeyID" requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/UpdateTeamAPIKey' + $ref: "#/components/schemas/UpdateTeamAPIKey" responses: - '200': + "200": description: Team API key updated successfully - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - '500': - $ref: '#/components/responses/500' + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" delete: description: Delete a team API key tags: [api-keys] @@ -3269,16 +3331,16 @@ paths: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] parameters: - - $ref: '#/components/parameters/apiKeyID' + - $ref: "#/components/parameters/apiKeyID" responses: - '204': + "204": description: Team API key deleted successfully - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - '500': - $ref: '#/components/responses/500' + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" /volumes: get: @@ -3289,18 +3351,18 @@ paths: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] responses: - '200': + "200": description: Successfully listed all team volumes content: application/json: schema: type: array items: - $ref: '#/components/schemas/Volume' - '401': - $ref: '#/components/responses/401' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/Volume" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" post: description: Create a new team volume @@ -3314,20 +3376,20 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/NewVolume' + $ref: "#/components/schemas/NewVolume" responses: - '201': + "201": description: Successfully created a new team volume content: application/json: schema: - $ref: '#/components/schemas/VolumeAndToken' - '400': - $ref: '#/components/responses/400' - '401': - $ref: '#/components/responses/401' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/VolumeAndToken" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" /volumes/{volumeID}: get: @@ -3338,20 +3400,20 @@ paths: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] parameters: - - $ref: '#/components/parameters/volumeID' + - $ref: "#/components/parameters/volumeID" responses: - '200': + "200": description: Successfully retrieved a team volume content: application/json: schema: - $ref: '#/components/schemas/VolumeAndToken' - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - '500': - $ref: '#/components/responses/500' + $ref: "#/components/schemas/VolumeAndToken" + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" delete: description: Delete a team volume @@ -3361,13 +3423,13 @@ paths: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] parameters: - - $ref: '#/components/parameters/volumeID' + - $ref: "#/components/parameters/volumeID" responses: - '204': + "204": description: Successfully deleted a team volume - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - '500': - $ref: '#/components/responses/500' + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" \ No newline at end of file From f8ea7fad38c63d61a0c6ba11f005dbcdb492c85e Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:36:43 +0200 Subject: [PATCH 12/23] feat(js-sdk): accept Map for network.rules SandboxNetworkRules now accepts both a plain object and a Map. The helper normalizes either form to a Map for the selector context and serializes via Object.fromEntries for the wire body. Co-Authored-By: Claude Opus 4.7 --- packages/js-sdk/src/sandbox/sandboxApi.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 786d7c7fac..098ec7ba54 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -55,10 +55,13 @@ export type SandboxNetworkRule = { /** * Map of host (or CIDR / IP) to ordered list of rules applied to outbound - * requests for that host. Registering a host here does not allow egress on - * its own — the host must also appear in {@link SandboxNetworkOpts.allowOut}. + * requests for that host. Accepts either a plain object or a `Map`. + * Registering a host here does not allow egress on its own — the host must + * also appear in {@link SandboxNetworkOpts.allowOut}. */ -export type SandboxNetworkRules = Record +export type SandboxNetworkRules = + | Record + | Map /** * Context passed to {@link SandboxNetworkOpts.allowOut} and @@ -519,14 +522,19 @@ function buildNetworkBody( return undefined } - const rules = new Map(Object.entries(network.rules ?? {})) + const rules = + network.rules instanceof Map + ? network.rules + : new Map(Object.entries(network.rules ?? {})) const allowOut = resolveNetworkSelector(network.allowOut, rules) const denyOut = resolveNetworkSelector(network.denyOut, rules) return { ...(allowOut !== undefined ? { allowOut } : {}), ...(denyOut !== undefined ? { denyOut } : {}), - ...(network.rules !== undefined ? { rules: network.rules } : {}), + ...(network.rules !== undefined + ? { rules: Object.fromEntries(rules) } + : {}), ...(network.allowPublicTraffic !== undefined ? { allowPublicTraffic: network.allowPublicTraffic } : {}), From 0653579936205d144ad98cfc09af573f2219b332 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:55:42 +0200 Subject: [PATCH 13/23] feat(sdk): add transform callback with placeholder context Network rules can now declare transforms as a callback that receives a typed context exposing literal placeholder strings (sandboxId, teamId, executionId, identity.jwt). The proxy resolves these per request at egress, so the SDK serializes the resolved object as-is and users get typed access without hardcoding template strings. Co-Authored-By: Claude Opus 4.7 --- packages/js-sdk/src/index.ts | 1 + packages/js-sdk/src/sandbox/sandboxApi.ts | 57 ++++++++++++++++++- packages/python-sdk/e2b/__init__.py | 2 + .../python-sdk/e2b/sandbox/sandbox_api.py | 55 +++++++++++++++++- 4 files changed, 110 insertions(+), 5 deletions(-) diff --git a/packages/js-sdk/src/index.ts b/packages/js-sdk/src/index.ts index 9713a54bf7..7f3a80dcf7 100644 --- a/packages/js-sdk/src/index.ts +++ b/packages/js-sdk/src/index.ts @@ -64,6 +64,7 @@ export type { SandboxNetworkRule, SandboxNetworkRules, SandboxNetworkTransform, + SandboxNetworkTransformContext, SandboxLifecycle, SandboxInfoLifecycle, SnapshotInfo, diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 098ec7ba54..21c2962a5f 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -45,12 +45,37 @@ export type SandboxNetworkTransform = { headers?: Record } +/** + * Context passed to a {@link SandboxNetworkRule} `transform` callback. Each + * field is a literal placeholder string (e.g. `'${e2b.sandboxId}'`) that the + * proxy resolves per request at egress time. + */ +export type SandboxNetworkTransformContext = { + /** Placeholder `'${e2b.sandboxId}'`. */ + sandboxId: string + /** Placeholder `'${e2b.teamId}'`. */ + teamId: string + /** Placeholder `'${e2b.executionId}'`. */ + executionId: string + /** Identity-related placeholders. */ + identity: { + /** Placeholder `'${e2b.identity.jwt}'`. */ + jwt: string + } +} + /** * Per-domain rule applied to egress requests. */ export type SandboxNetworkRule = { - /** Transform applied to requests matching this rule. */ - transform?: SandboxNetworkTransform + /** + * Transform applied to requests matching this rule. Accepts either a static + * object or a callback that receives a {@link SandboxNetworkTransformContext} + * of placeholder strings; the resolved object is sent to the API as-is. + */ + transform?: + | SandboxNetworkTransform + | ((ctx: SandboxNetworkTransformContext) => SandboxNetworkTransform) } /** @@ -515,6 +540,32 @@ function resolveNetworkSelector( return selector } +const TRANSFORM_CONTEXT: SandboxNetworkTransformContext = { + sandboxId: '${e2b.sandboxId}', + teamId: '${e2b.teamId}', + executionId: '${e2b.executionId}', + identity: { + jwt: '${e2b.identity.jwt}', + }, +} + +function resolveRulesForBody( + rules: Map +): Record { + const out: Record = {} + for (const [host, hostRules] of rules) { + out[host] = hostRules.map((rule) => { + if (rule.transform === undefined) return {} + const transform = + typeof rule.transform === 'function' + ? rule.transform(TRANSFORM_CONTEXT) + : rule.transform + return { transform } + }) + } + return out +} + function buildNetworkBody( network: SandboxNetworkOpts | undefined ): components['schemas']['SandboxNetworkConfig'] | undefined { @@ -533,7 +584,7 @@ function buildNetworkBody( ...(allowOut !== undefined ? { allowOut } : {}), ...(denyOut !== undefined ? { denyOut } : {}), ...(network.rules !== undefined - ? { rules: Object.fromEntries(rules) } + ? { rules: resolveRulesForBody(rules) } : {}), ...(network.allowPublicTraffic !== undefined ? { allowPublicTraffic: network.allowPublicTraffic } diff --git a/packages/python-sdk/e2b/__init__.py b/packages/python-sdk/e2b/__init__.py index c8d17faaee..75abda7a1b 100644 --- a/packages/python-sdk/e2b/__init__.py +++ b/packages/python-sdk/e2b/__init__.py @@ -86,6 +86,7 @@ SandboxNetworkSelector, SandboxNetworkSelectorContext, SandboxNetworkTransform, + SandboxNetworkTransformContext, SandboxQuery, SandboxState, SnapshotInfo, @@ -195,6 +196,7 @@ "SandboxNetworkRule", "SandboxNetworkRules", "SandboxNetworkTransform", + "SandboxNetworkTransformContext", "SandboxLifecycle", "ALL_TRAFFIC", # Snapshot diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index 692fd4df4d..762ca35cb4 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -73,13 +73,54 @@ class SandboxNetworkTransform(TypedDict): """ +@dataclass(frozen=True) +class _SandboxNetworkTransformIdentity: + """Identity-related placeholders.""" + + jwt: str + """Placeholder ``"${e2b.identity.jwt}"``.""" + + +@dataclass(frozen=True) +class SandboxNetworkTransformContext: + """ + Context passed to a :class:`SandboxNetworkRule` ``transform`` callable. + Each field is a literal placeholder string (e.g. ``"${e2b.sandbox_id}"``) + that the proxy resolves per request at egress time. + """ + + sandbox_id: str + """Placeholder ``"${e2b.sandboxId}"``.""" + + team_id: str + """Placeholder ``"${e2b.teamId}"``.""" + + execution_id: str + """Placeholder ``"${e2b.executionId}"``.""" + + identity: _SandboxNetworkTransformIdentity + """Identity-related placeholders.""" + + +SandboxNetworkTransformResolver = Callable[ + [SandboxNetworkTransformContext], SandboxNetworkTransform +] + + class SandboxNetworkRule(TypedDict): """ Per-domain rule applied to egress requests. """ - transform: NotRequired[SandboxNetworkTransform] - """Transform applied to requests matching this rule.""" + transform: NotRequired[ + Union[SandboxNetworkTransform, SandboxNetworkTransformResolver] + ] + """ + Transform applied to requests matching this rule. Accepts either a static + :class:`SandboxNetworkTransform` or a callable that receives a + :class:`SandboxNetworkTransformContext` of placeholder strings; the + resolved object is sent to the API as-is. + """ SandboxNetworkRules = Dict[str, List[SandboxNetworkRule]] @@ -235,6 +276,14 @@ def _resolve_network_selector( return list(selector) +_TRANSFORM_CONTEXT = SandboxNetworkTransformContext( + sandbox_id="${e2b.sandboxId}", + team_id="${e2b.teamId}", + execution_id="${e2b.executionId}", + identity=_SandboxNetworkTransformIdentity(jwt="${e2b.identity.jwt}"), +) + + def _build_client_rules(rules: SandboxNetworkRules) -> SandboxNetworkConfigRules: client_rules = SandboxNetworkConfigRules() for host, host_rules in rules.items(): @@ -244,6 +293,8 @@ def _build_client_rules(rules: SandboxNetworkRules) -> SandboxNetworkConfigRules if transform is None: converted.append(ClientSandboxNetworkRule()) continue + if callable(transform): + transform = transform(_TRANSFORM_CONTEXT) client_transform = ClientSandboxNetworkTransform() headers = transform.get("headers") From 42eae980a8449909172db6f1291258f916af4bcc Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Wed, 13 May 2026 15:40:49 +0200 Subject: [PATCH 14/23] updated tests --- packages/js-sdk/tests/sandbox/network.test.ts | 1 - packages/python-sdk/tests/async/sandbox_async/test_network.py | 1 - packages/python-sdk/tests/sync/sandbox_sync/test_network.py | 1 - 3 files changed, 3 deletions(-) diff --git a/packages/js-sdk/tests/sandbox/network.test.ts b/packages/js-sdk/tests/sandbox/network.test.ts index 31b4806d04..7b5aa9eee9 100644 --- a/packages/js-sdk/tests/sandbox/network.test.ts +++ b/packages/js-sdk/tests/sandbox/network.test.ts @@ -200,7 +200,6 @@ describe('firewall transform injects headers', () => { sandboxTest.scoped({ sandboxOpts: { network: { - allowOut: ({ rules }) => [...rules.keys()], rules: { 'httpbin.org': [ { diff --git a/packages/python-sdk/tests/async/sandbox_async/test_network.py b/packages/python-sdk/tests/async/sandbox_async/test_network.py index c41e214889..0636160cc0 100644 --- a/packages/python-sdk/tests/async/sandbox_async/test_network.py +++ b/packages/python-sdk/tests/async/sandbox_async/test_network.py @@ -170,7 +170,6 @@ async def test_firewall_transform_injects_headers(async_sandbox_factory): injected_value = "e2b-transform-value-123" network: SandboxNetworkOpts = { - "allow_out": lambda ctx: list(ctx.rules.keys()), "rules": { "httpbin.org": [ {"transform": {"headers": {injected_header: injected_value}}}, diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_network.py b/packages/python-sdk/tests/sync/sandbox_sync/test_network.py index 689553e159..17511f49a5 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_network.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_network.py @@ -168,7 +168,6 @@ def test_firewall_transform_injects_headers(sandbox_factory): injected_value = "e2b-transform-value-123" network: SandboxNetworkOpts = { - "allow_out": lambda ctx: list(ctx.rules.keys()), "rules": { "httpbin.org": [ {"transform": {"headers": {injected_header: injected_value}}}, From 730165a4867d92c644c45d2903232ca507d1e10e Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Wed, 13 May 2026 20:13:17 +0200 Subject: [PATCH 15/23] added changeset --- .changeset/network-rules-transform.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/network-rules-transform.md diff --git a/.changeset/network-rules-transform.md b/.changeset/network-rules-transform.md new file mode 100644 index 0000000000..1ebb005252 --- /dev/null +++ b/.changeset/network-rules-transform.md @@ -0,0 +1,6 @@ +--- +'e2b': minor +'@e2b/python-sdk': minor +--- + +Support structured network rules with per-host transforms From fbefba3064d67b5a723f85bd21d58272d3b8436f Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 14 May 2026 14:30:24 +0200 Subject: [PATCH 16/23] test(network): assert sandboxId placeholder resolves at egress Inject `ctx.sandboxId` as a header on the httpbin rule and verify the reflected response matches the live `sandbox.sandbox_id`, proving the proxy substituted `${e2b.sandboxId}` per request. Co-Authored-By: Claude Opus 4.7 --- packages/js-sdk/tests/sandbox/network.test.ts | 39 +++++++++++++++++++ .../tests/async/sandbox_async/test_network.py | 29 ++++++++++++++ .../tests/sync/sandbox_sync/test_network.py | 27 +++++++++++++ 3 files changed, 95 insertions(+) diff --git a/packages/js-sdk/tests/sandbox/network.test.ts b/packages/js-sdk/tests/sandbox/network.test.ts index 7b5aa9eee9..f09a657805 100644 --- a/packages/js-sdk/tests/sandbox/network.test.ts +++ b/packages/js-sdk/tests/sandbox/network.test.ts @@ -236,6 +236,45 @@ describe('firewall transform injects headers', () => { ) }) +describe('transform callback resolves sandboxId placeholder', () => { + const headerName = 'X-E2B-Sandbox-Id' + + sandboxTest.scoped({ + sandboxOpts: { + network: { + rules: { + 'httpbin.org': [ + { + transform: ({ sandboxId }) => ({ + headers: { [headerName]: sandboxId }, + }), + }, + ], + }, + }, + }, + }) + + sandboxTest.skipIf(isDebug)( + 'placeholder is replaced with the actual sandbox id', + async ({ sandbox }) => { + const result = await sandbox.commands.run( + 'curl -sS --max-time 10 https://httpbin.org/headers' + ) + assert.equal(result.exitCode, 0) + + const parsed = JSON.parse(result.stdout) as { + headers: Record + } + assert.equal( + parsed.headers[headerName], + sandbox.sandboxId, + `expected httpbin to reflect ${headerName}=${sandbox.sandboxId}, got headers: ${JSON.stringify(parsed.headers)}` + ) + } + ) +}) + describe('maskRequestHost option', () => { sandboxTest.scoped({ sandboxOpts: { diff --git a/packages/python-sdk/tests/async/sandbox_async/test_network.py b/packages/python-sdk/tests/async/sandbox_async/test_network.py index 0636160cc0..a186e944d6 100644 --- a/packages/python-sdk/tests/async/sandbox_async/test_network.py +++ b/packages/python-sdk/tests/async/sandbox_async/test_network.py @@ -191,6 +191,35 @@ async def test_firewall_transform_injects_headers(async_sandbox_factory): ) +@pytest.mark.skip_debug() +async def test_transform_callback_resolves_sandbox_id(async_sandbox_factory): + """The transform callback's sandbox_id placeholder is resolved by the proxy.""" + header_name = "X-E2B-Sandbox-Id" + + network: SandboxNetworkOpts = { + "rules": { + "httpbin.org": [ + { + "transform": lambda ctx: {"headers": {header_name: ctx.sandbox_id}}, + }, + ], + }, + } + async_sandbox = await async_sandbox_factory(network=network) + + result = await async_sandbox.commands.run( + "curl -sS --max-time 10 https://httpbin.org/headers" + ) + assert result.exit_code == 0 + + parsed = json.loads(result.stdout) + reflected = parsed["headers"].get(header_name) + assert reflected == async_sandbox.sandbox_id, ( + f"expected httpbin to reflect {header_name}={async_sandbox.sandbox_id}, " + f"got headers: {parsed['headers']}" + ) + + @pytest.mark.skip_debug() async def test_mask_request_host(async_sandbox_factory): """Test that mask_request_host modifies the Host header correctly.""" diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_network.py b/packages/python-sdk/tests/sync/sandbox_sync/test_network.py index 17511f49a5..2f5f555473 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_network.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_network.py @@ -187,6 +187,33 @@ def test_firewall_transform_injects_headers(sandbox_factory): ) +@pytest.mark.skip_debug() +def test_transform_callback_resolves_sandbox_id(sandbox_factory): + """The transform callback's sandbox_id placeholder is resolved by the proxy.""" + header_name = "X-E2B-Sandbox-Id" + + network: SandboxNetworkOpts = { + "rules": { + "httpbin.org": [ + { + "transform": lambda ctx: {"headers": {header_name: ctx.sandbox_id}}, + }, + ], + }, + } + sandbox = sandbox_factory(network=network) + + result = sandbox.commands.run("curl -sS --max-time 10 https://httpbin.org/headers") + assert result.exit_code == 0 + + parsed = json.loads(result.stdout) + reflected = parsed["headers"].get(header_name) + assert reflected == sandbox.sandbox_id, ( + f"expected httpbin to reflect {header_name}={sandbox.sandbox_id}, " + f"got headers: {parsed['headers']}" + ) + + @pytest.mark.skip_debug() def test_mask_request_host(sandbox_factory): """Test that mask_request_host modifies the Host header correctly.""" From 1663cb27528cc5a0edb343502144c1eb871c0857 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 14 May 2026 14:59:12 +0200 Subject: [PATCH 17/23] fix(sdk): narrow SandboxNetworkInfo.rules to static shape API responses can only contain plain JSON, so rules in the info shape must not allow the JS Map variant or callback transforms accepted by the input types. Introduce SandboxNetworkRuleInfo mirroring SandboxNetworkRule with transform fixed to the static SandboxNetworkTransform, and use it for SandboxNetworkInfo.rules in both SDKs. Co-Authored-By: Claude Opus 4.7 --- packages/js-sdk/src/index.ts | 1 + packages/js-sdk/src/sandbox/sandboxApi.ts | 11 ++++++++++- packages/python-sdk/e2b/__init__.py | 2 ++ packages/python-sdk/e2b/sandbox/sandbox_api.py | 16 ++++++++++++++-- 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/js-sdk/src/index.ts b/packages/js-sdk/src/index.ts index 7f3a80dcf7..5111767003 100644 --- a/packages/js-sdk/src/index.ts +++ b/packages/js-sdk/src/index.ts @@ -62,6 +62,7 @@ export type { SandboxNetworkSelector, SandboxNetworkSelectorContext, SandboxNetworkRule, + SandboxNetworkRuleInfo, SandboxNetworkRules, SandboxNetworkTransform, SandboxNetworkTransformContext, diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 21c2962a5f..6b69a742ee 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -88,6 +88,15 @@ export type SandboxNetworkRules = | Record | Map +/** + * Per-domain rule as returned by the sandbox info endpoint. Mirrors + * {@link SandboxNetworkRule} but with `transform` always materialized to the + * static {@link SandboxNetworkTransform} shape — no callback variant. + */ +export type SandboxNetworkRuleInfo = { + transform?: SandboxNetworkTransform +} + /** * Context passed to {@link SandboxNetworkOpts.allowOut} and * {@link SandboxNetworkOpts.denyOut} when they are defined as functions. @@ -183,7 +192,7 @@ export type SandboxNetworkOpts = { export type SandboxNetworkInfo = { allowOut?: string[] denyOut?: string[] - rules?: SandboxNetworkRules + rules?: Record allowPublicTraffic?: boolean maskRequestHost?: string } diff --git a/packages/python-sdk/e2b/__init__.py b/packages/python-sdk/e2b/__init__.py index 75abda7a1b..ffc9800bbe 100644 --- a/packages/python-sdk/e2b/__init__.py +++ b/packages/python-sdk/e2b/__init__.py @@ -82,6 +82,7 @@ SandboxNetworkInfo, SandboxNetworkOpts, SandboxNetworkRule, + SandboxNetworkRuleInfo, SandboxNetworkRules, SandboxNetworkSelector, SandboxNetworkSelectorContext, @@ -194,6 +195,7 @@ "SandboxNetworkSelector", "SandboxNetworkSelectorContext", "SandboxNetworkRule", + "SandboxNetworkRuleInfo", "SandboxNetworkRules", "SandboxNetworkTransform", "SandboxNetworkTransformContext", diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index 762ca35cb4..8c2b9ecdcb 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -131,6 +131,16 @@ class SandboxNetworkRule(TypedDict): """ +class SandboxNetworkRuleInfo(TypedDict): + """ + Per-domain rule as returned by the sandbox info endpoint. Mirrors + :class:`SandboxNetworkRule` but with ``transform`` always materialized to + the static :class:`SandboxNetworkTransform` shape — no callable variant. + """ + + transform: NotRequired[SandboxNetworkTransform] + + @dataclass(frozen=True) class SandboxNetworkSelectorContext: """ @@ -223,7 +233,7 @@ class SandboxNetworkInfo(TypedDict, total=False): allow_out: List[str] deny_out: List[str] - rules: SandboxNetworkRules + rules: Dict[str, List[SandboxNetworkRuleInfo]] allow_public_traffic: bool mask_request_host: str @@ -355,7 +365,9 @@ def from_client_network_config( if not isinstance(network.deny_out, Unset): result["deny_out"] = list(network.deny_out) if not isinstance(network.rules, Unset): - result["rules"] = cast(SandboxNetworkRules, network.rules.to_dict()) + result["rules"] = cast( + Dict[str, List[SandboxNetworkRuleInfo]], network.rules.to_dict() + ) if not isinstance(network.allow_public_traffic, Unset): result["allow_public_traffic"] = network.allow_public_traffic if not isinstance(network.mask_request_host, Unset): From 40c8f002b85839efc0da8dfbd0185144ec5f41f7 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Fri, 15 May 2026 18:31:31 +0200 Subject: [PATCH 18/23] test(network): point transform tests at httpbin.e2b.team MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We host our own httpbin mirror at httpbin.e2b.team — switch the rule host and curl target away from httpbin.org so tests don't depend on a third-party service. Co-Authored-By: Claude Opus 4.7 --- packages/js-sdk/tests/sandbox/network.test.ts | 10 +++++----- .../tests/async/sandbox_async/test_network.py | 8 ++++---- .../python-sdk/tests/sync/sandbox_sync/test_network.py | 8 ++++---- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/js-sdk/tests/sandbox/network.test.ts b/packages/js-sdk/tests/sandbox/network.test.ts index f09a657805..ddec3d32d0 100644 --- a/packages/js-sdk/tests/sandbox/network.test.ts +++ b/packages/js-sdk/tests/sandbox/network.test.ts @@ -201,7 +201,7 @@ describe('firewall transform injects headers', () => { sandboxOpts: { network: { rules: { - 'httpbin.org': [ + 'httpbin.e2b.team': [ { transform: { headers: { @@ -216,10 +216,10 @@ describe('firewall transform injects headers', () => { }) sandboxTest.skipIf(isDebug)( - 'injected header is reflected by httpbin.org/headers', + 'injected header is reflected by httpbin.e2b.team/headers', async ({ sandbox }) => { const result = await sandbox.commands.run( - 'curl -sS --max-time 10 https://httpbin.org/headers' + 'curl -sS --max-time 10 https://httpbin.e2b.team/headers' ) assert.equal(result.exitCode, 0) @@ -243,7 +243,7 @@ describe('transform callback resolves sandboxId placeholder', () => { sandboxOpts: { network: { rules: { - 'httpbin.org': [ + 'httpbin.e2b.team': [ { transform: ({ sandboxId }) => ({ headers: { [headerName]: sandboxId }, @@ -259,7 +259,7 @@ describe('transform callback resolves sandboxId placeholder', () => { 'placeholder is replaced with the actual sandbox id', async ({ sandbox }) => { const result = await sandbox.commands.run( - 'curl -sS --max-time 10 https://httpbin.org/headers' + 'curl -sS --max-time 10 https://httpbin.e2b.team/headers' ) assert.equal(result.exitCode, 0) diff --git a/packages/python-sdk/tests/async/sandbox_async/test_network.py b/packages/python-sdk/tests/async/sandbox_async/test_network.py index a186e944d6..6181dfbef0 100644 --- a/packages/python-sdk/tests/async/sandbox_async/test_network.py +++ b/packages/python-sdk/tests/async/sandbox_async/test_network.py @@ -171,7 +171,7 @@ async def test_firewall_transform_injects_headers(async_sandbox_factory): network: SandboxNetworkOpts = { "rules": { - "httpbin.org": [ + "httpbin.e2b.team": [ {"transform": {"headers": {injected_header: injected_value}}}, ], }, @@ -179,7 +179,7 @@ async def test_firewall_transform_injects_headers(async_sandbox_factory): async_sandbox = await async_sandbox_factory(network=network) result = await async_sandbox.commands.run( - "curl -sS --max-time 10 https://httpbin.org/headers" + "curl -sS --max-time 10 https://httpbin.e2b.team/headers" ) assert result.exit_code == 0 @@ -198,7 +198,7 @@ async def test_transform_callback_resolves_sandbox_id(async_sandbox_factory): network: SandboxNetworkOpts = { "rules": { - "httpbin.org": [ + "httpbin.e2b.team": [ { "transform": lambda ctx: {"headers": {header_name: ctx.sandbox_id}}, }, @@ -208,7 +208,7 @@ async def test_transform_callback_resolves_sandbox_id(async_sandbox_factory): async_sandbox = await async_sandbox_factory(network=network) result = await async_sandbox.commands.run( - "curl -sS --max-time 10 https://httpbin.org/headers" + "curl -sS --max-time 10 https://httpbin.e2b.team/headers" ) assert result.exit_code == 0 diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_network.py b/packages/python-sdk/tests/sync/sandbox_sync/test_network.py index 2f5f555473..0aebe1002c 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_network.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_network.py @@ -169,14 +169,14 @@ def test_firewall_transform_injects_headers(sandbox_factory): network: SandboxNetworkOpts = { "rules": { - "httpbin.org": [ + "httpbin.e2b.team": [ {"transform": {"headers": {injected_header: injected_value}}}, ], }, } sandbox = sandbox_factory(network=network) - result = sandbox.commands.run("curl -sS --max-time 10 https://httpbin.org/headers") + result = sandbox.commands.run("curl -sS --max-time 10 https://httpbin.e2b.team/headers") assert result.exit_code == 0 parsed = json.loads(result.stdout) @@ -194,7 +194,7 @@ def test_transform_callback_resolves_sandbox_id(sandbox_factory): network: SandboxNetworkOpts = { "rules": { - "httpbin.org": [ + "httpbin.e2b.team": [ { "transform": lambda ctx: {"headers": {header_name: ctx.sandbox_id}}, }, @@ -203,7 +203,7 @@ def test_transform_callback_resolves_sandbox_id(sandbox_factory): } sandbox = sandbox_factory(network=network) - result = sandbox.commands.run("curl -sS --max-time 10 https://httpbin.org/headers") + result = sandbox.commands.run("curl -sS --max-time 10 https://httpbin.e2b.team/headers") assert result.exit_code == 0 parsed = json.loads(result.stdout) From 708c7b5c5090013d549c61657af7675968bf66d9 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Fri, 15 May 2026 18:35:12 +0200 Subject: [PATCH 19/23] chore(python-sdk): ruff-format wrap curl test line Co-Authored-By: Claude Opus 4.7 --- .../python-sdk/tests/sync/sandbox_sync/test_network.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_network.py b/packages/python-sdk/tests/sync/sandbox_sync/test_network.py index 0aebe1002c..6bd8a9e607 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_network.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_network.py @@ -176,7 +176,9 @@ def test_firewall_transform_injects_headers(sandbox_factory): } sandbox = sandbox_factory(network=network) - result = sandbox.commands.run("curl -sS --max-time 10 https://httpbin.e2b.team/headers") + result = sandbox.commands.run( + "curl -sS --max-time 10 https://httpbin.e2b.team/headers" + ) assert result.exit_code == 0 parsed = json.loads(result.stdout) @@ -203,7 +205,9 @@ def test_transform_callback_resolves_sandbox_id(sandbox_factory): } sandbox = sandbox_factory(network=network) - result = sandbox.commands.run("curl -sS --max-time 10 https://httpbin.e2b.team/headers") + result = sandbox.commands.run( + "curl -sS --max-time 10 https://httpbin.e2b.team/headers" + ) assert result.exit_code == 0 parsed = json.loads(result.stdout) From 8d32f299e544db8c435f75407b4403cad704f7b1 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Mon, 18 May 2026 16:41:51 +0200 Subject: [PATCH 20/23] test(network): disable transform placeholder resolution tests Comment out the sandboxId placeholder tests until the proxy-side placeholder resolution (e2b.identity.jwt, e2b.sandboxId, etc.) is in place. Co-Authored-By: Claude Opus 4.7 --- packages/js-sdk/tests/sandbox/network.test.ts | 76 +++++++++---------- .../tests/async/sandbox_async/test_network.py | 54 ++++++------- .../tests/sync/sandbox_sync/test_network.py | 54 ++++++------- 3 files changed, 92 insertions(+), 92 deletions(-) diff --git a/packages/js-sdk/tests/sandbox/network.test.ts b/packages/js-sdk/tests/sandbox/network.test.ts index ddec3d32d0..08f01be91e 100644 --- a/packages/js-sdk/tests/sandbox/network.test.ts +++ b/packages/js-sdk/tests/sandbox/network.test.ts @@ -236,44 +236,44 @@ describe('firewall transform injects headers', () => { ) }) -describe('transform callback resolves sandboxId placeholder', () => { - const headerName = 'X-E2B-Sandbox-Id' - - sandboxTest.scoped({ - sandboxOpts: { - network: { - rules: { - 'httpbin.e2b.team': [ - { - transform: ({ sandboxId }) => ({ - headers: { [headerName]: sandboxId }, - }), - }, - ], - }, - }, - }, - }) - - sandboxTest.skipIf(isDebug)( - 'placeholder is replaced with the actual sandbox id', - async ({ sandbox }) => { - const result = await sandbox.commands.run( - 'curl -sS --max-time 10 https://httpbin.e2b.team/headers' - ) - assert.equal(result.exitCode, 0) - - const parsed = JSON.parse(result.stdout) as { - headers: Record - } - assert.equal( - parsed.headers[headerName], - sandbox.sandboxId, - `expected httpbin to reflect ${headerName}=${sandbox.sandboxId}, got headers: ${JSON.stringify(parsed.headers)}` - ) - } - ) -}) +// describe('transform callback resolves sandboxId placeholder', () => { +// const headerName = 'X-E2B-Sandbox-Id' +// +// sandboxTest.scoped({ +// sandboxOpts: { +// network: { +// rules: { +// 'httpbin.e2b.team': [ +// { +// transform: ({ sandboxId }) => ({ +// headers: { [headerName]: sandboxId }, +// }), +// }, +// ], +// }, +// }, +// }, +// }) +// +// sandboxTest.skipIf(isDebug)( +// 'placeholder is replaced with the actual sandbox id', +// async ({ sandbox }) => { +// const result = await sandbox.commands.run( +// 'curl -sS --max-time 10 https://httpbin.e2b.team/headers' +// ) +// assert.equal(result.exitCode, 0) +// +// const parsed = JSON.parse(result.stdout) as { +// headers: Record +// } +// assert.equal( +// parsed.headers[headerName], +// sandbox.sandboxId, +// `expected httpbin to reflect ${headerName}=${sandbox.sandboxId}, got headers: ${JSON.stringify(parsed.headers)}` +// ) +// } +// ) +// }) describe('maskRequestHost option', () => { sandboxTest.scoped({ diff --git a/packages/python-sdk/tests/async/sandbox_async/test_network.py b/packages/python-sdk/tests/async/sandbox_async/test_network.py index 6181dfbef0..d8c0c1d250 100644 --- a/packages/python-sdk/tests/async/sandbox_async/test_network.py +++ b/packages/python-sdk/tests/async/sandbox_async/test_network.py @@ -191,33 +191,33 @@ async def test_firewall_transform_injects_headers(async_sandbox_factory): ) -@pytest.mark.skip_debug() -async def test_transform_callback_resolves_sandbox_id(async_sandbox_factory): - """The transform callback's sandbox_id placeholder is resolved by the proxy.""" - header_name = "X-E2B-Sandbox-Id" - - network: SandboxNetworkOpts = { - "rules": { - "httpbin.e2b.team": [ - { - "transform": lambda ctx: {"headers": {header_name: ctx.sandbox_id}}, - }, - ], - }, - } - async_sandbox = await async_sandbox_factory(network=network) - - result = await async_sandbox.commands.run( - "curl -sS --max-time 10 https://httpbin.e2b.team/headers" - ) - assert result.exit_code == 0 - - parsed = json.loads(result.stdout) - reflected = parsed["headers"].get(header_name) - assert reflected == async_sandbox.sandbox_id, ( - f"expected httpbin to reflect {header_name}={async_sandbox.sandbox_id}, " - f"got headers: {parsed['headers']}" - ) +# @pytest.mark.skip_debug() +# async def test_transform_callback_resolves_sandbox_id(async_sandbox_factory): +# """The transform callback's sandbox_id placeholder is resolved by the proxy.""" +# header_name = "X-E2B-Sandbox-Id" +# +# network: SandboxNetworkOpts = { +# "rules": { +# "httpbin.e2b.team": [ +# { +# "transform": lambda ctx: {"headers": {header_name: ctx.sandbox_id}}, +# }, +# ], +# }, +# } +# async_sandbox = await async_sandbox_factory(network=network) +# +# result = await async_sandbox.commands.run( +# "curl -sS --max-time 10 https://httpbin.e2b.team/headers" +# ) +# assert result.exit_code == 0 +# +# parsed = json.loads(result.stdout) +# reflected = parsed["headers"].get(header_name) +# assert reflected == async_sandbox.sandbox_id, ( +# f"expected httpbin to reflect {header_name}={async_sandbox.sandbox_id}, " +# f"got headers: {parsed['headers']}" +# ) @pytest.mark.skip_debug() diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_network.py b/packages/python-sdk/tests/sync/sandbox_sync/test_network.py index 6bd8a9e607..29c51cc76e 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_network.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_network.py @@ -189,33 +189,33 @@ def test_firewall_transform_injects_headers(sandbox_factory): ) -@pytest.mark.skip_debug() -def test_transform_callback_resolves_sandbox_id(sandbox_factory): - """The transform callback's sandbox_id placeholder is resolved by the proxy.""" - header_name = "X-E2B-Sandbox-Id" - - network: SandboxNetworkOpts = { - "rules": { - "httpbin.e2b.team": [ - { - "transform": lambda ctx: {"headers": {header_name: ctx.sandbox_id}}, - }, - ], - }, - } - sandbox = sandbox_factory(network=network) - - result = sandbox.commands.run( - "curl -sS --max-time 10 https://httpbin.e2b.team/headers" - ) - assert result.exit_code == 0 - - parsed = json.loads(result.stdout) - reflected = parsed["headers"].get(header_name) - assert reflected == sandbox.sandbox_id, ( - f"expected httpbin to reflect {header_name}={sandbox.sandbox_id}, " - f"got headers: {parsed['headers']}" - ) +# @pytest.mark.skip_debug() +# def test_transform_callback_resolves_sandbox_id(sandbox_factory): +# """The transform callback's sandbox_id placeholder is resolved by the proxy.""" +# header_name = "X-E2B-Sandbox-Id" +# +# network: SandboxNetworkOpts = { +# "rules": { +# "httpbin.e2b.team": [ +# { +# "transform": lambda ctx: {"headers": {header_name: ctx.sandbox_id}}, +# }, +# ], +# }, +# } +# sandbox = sandbox_factory(network=network) +# +# result = sandbox.commands.run( +# "curl -sS --max-time 10 https://httpbin.e2b.team/headers" +# ) +# assert result.exit_code == 0 +# +# parsed = json.loads(result.stdout) +# reflected = parsed["headers"].get(header_name) +# assert reflected == sandbox.sandbox_id, ( +# f"expected httpbin to reflect {header_name}={sandbox.sandbox_id}, " +# f"got headers: {parsed['headers']}" +# ) @pytest.mark.skip_debug() From 6b939d21b61233faf0ba9fe2ced89d79d9ddf5f0 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Mon, 18 May 2026 16:52:04 +0200 Subject: [PATCH 21/23] fix(sdk): freeze shared TRANSFORM_CONTEXT and mark fields readonly Prevents user transform callbacks from mutating the module-level placeholder context and corrupting subsequent sandbox creations. Co-Authored-By: Claude Opus 4.7 --- packages/js-sdk/src/sandbox/sandboxApi.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 6b69a742ee..60a22b1739 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -52,15 +52,15 @@ export type SandboxNetworkTransform = { */ export type SandboxNetworkTransformContext = { /** Placeholder `'${e2b.sandboxId}'`. */ - sandboxId: string + readonly sandboxId: string /** Placeholder `'${e2b.teamId}'`. */ - teamId: string + readonly teamId: string /** Placeholder `'${e2b.executionId}'`. */ - executionId: string + readonly executionId: string /** Identity-related placeholders. */ - identity: { + readonly identity: { /** Placeholder `'${e2b.identity.jwt}'`. */ - jwt: string + readonly jwt: string } } @@ -549,14 +549,14 @@ function resolveNetworkSelector( return selector } -const TRANSFORM_CONTEXT: SandboxNetworkTransformContext = { +const TRANSFORM_CONTEXT: SandboxNetworkTransformContext = Object.freeze({ sandboxId: '${e2b.sandboxId}', teamId: '${e2b.teamId}', executionId: '${e2b.executionId}', - identity: { + identity: Object.freeze({ jwt: '${e2b.identity.jwt}', - }, -} + }), +}) function resolveRulesForBody( rules: Map From 0dec3afba3562ba12cbaa19f16f5a1afa0d8239e Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Tue, 19 May 2026 12:45:11 +0200 Subject: [PATCH 22/23] refactor(sdk): drop transform callback + placeholder context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 1 ships only static `transform: { headers: ... }` rules. The callback variant — `transform: ({ identity, sandboxId }) => (...)` — and its placeholder context (TRANSFORM_CONTEXT, SandboxNetworkTransformContext, SandboxNetworkTransformResolver) are removed for now; they'll return in a follow-up PR alongside the proxy-side placeholder resolution and tests. Co-Authored-By: Claude Opus 4.7 --- packages/js-sdk/src/index.ts | 1 - packages/js-sdk/src/sandbox/sandboxApi.ts | 47 ++-------------- packages/js-sdk/tests/sandbox/network.test.ts | 39 -------------- packages/python-sdk/e2b/__init__.py | 2 - .../python-sdk/e2b/sandbox/sandbox_api.py | 53 +------------------ .../tests/async/sandbox_async/test_network.py | 29 ---------- .../tests/sync/sandbox_sync/test_network.py | 29 ---------- 7 files changed, 7 insertions(+), 193 deletions(-) diff --git a/packages/js-sdk/src/index.ts b/packages/js-sdk/src/index.ts index 5111767003..74f29105b7 100644 --- a/packages/js-sdk/src/index.ts +++ b/packages/js-sdk/src/index.ts @@ -65,7 +65,6 @@ export type { SandboxNetworkRuleInfo, SandboxNetworkRules, SandboxNetworkTransform, - SandboxNetworkTransformContext, SandboxLifecycle, SandboxInfoLifecycle, SnapshotInfo, diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 60a22b1739..eebf663e26 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -45,37 +45,14 @@ export type SandboxNetworkTransform = { headers?: Record } -/** - * Context passed to a {@link SandboxNetworkRule} `transform` callback. Each - * field is a literal placeholder string (e.g. `'${e2b.sandboxId}'`) that the - * proxy resolves per request at egress time. - */ -export type SandboxNetworkTransformContext = { - /** Placeholder `'${e2b.sandboxId}'`. */ - readonly sandboxId: string - /** Placeholder `'${e2b.teamId}'`. */ - readonly teamId: string - /** Placeholder `'${e2b.executionId}'`. */ - readonly executionId: string - /** Identity-related placeholders. */ - readonly identity: { - /** Placeholder `'${e2b.identity.jwt}'`. */ - readonly jwt: string - } -} - /** * Per-domain rule applied to egress requests. */ export type SandboxNetworkRule = { /** - * Transform applied to requests matching this rule. Accepts either a static - * object or a callback that receives a {@link SandboxNetworkTransformContext} - * of placeholder strings; the resolved object is sent to the API as-is. + * Transform applied to requests matching this rule. */ - transform?: - | SandboxNetworkTransform - | ((ctx: SandboxNetworkTransformContext) => SandboxNetworkTransform) + transform?: SandboxNetworkTransform } /** @@ -549,28 +526,14 @@ function resolveNetworkSelector( return selector } -const TRANSFORM_CONTEXT: SandboxNetworkTransformContext = Object.freeze({ - sandboxId: '${e2b.sandboxId}', - teamId: '${e2b.teamId}', - executionId: '${e2b.executionId}', - identity: Object.freeze({ - jwt: '${e2b.identity.jwt}', - }), -}) - function resolveRulesForBody( rules: Map ): Record { const out: Record = {} for (const [host, hostRules] of rules) { - out[host] = hostRules.map((rule) => { - if (rule.transform === undefined) return {} - const transform = - typeof rule.transform === 'function' - ? rule.transform(TRANSFORM_CONTEXT) - : rule.transform - return { transform } - }) + out[host] = hostRules.map((rule) => + rule.transform === undefined ? {} : { transform: rule.transform } + ) } return out } diff --git a/packages/js-sdk/tests/sandbox/network.test.ts b/packages/js-sdk/tests/sandbox/network.test.ts index 08f01be91e..7388d98048 100644 --- a/packages/js-sdk/tests/sandbox/network.test.ts +++ b/packages/js-sdk/tests/sandbox/network.test.ts @@ -236,45 +236,6 @@ describe('firewall transform injects headers', () => { ) }) -// describe('transform callback resolves sandboxId placeholder', () => { -// const headerName = 'X-E2B-Sandbox-Id' -// -// sandboxTest.scoped({ -// sandboxOpts: { -// network: { -// rules: { -// 'httpbin.e2b.team': [ -// { -// transform: ({ sandboxId }) => ({ -// headers: { [headerName]: sandboxId }, -// }), -// }, -// ], -// }, -// }, -// }, -// }) -// -// sandboxTest.skipIf(isDebug)( -// 'placeholder is replaced with the actual sandbox id', -// async ({ sandbox }) => { -// const result = await sandbox.commands.run( -// 'curl -sS --max-time 10 https://httpbin.e2b.team/headers' -// ) -// assert.equal(result.exitCode, 0) -// -// const parsed = JSON.parse(result.stdout) as { -// headers: Record -// } -// assert.equal( -// parsed.headers[headerName], -// sandbox.sandboxId, -// `expected httpbin to reflect ${headerName}=${sandbox.sandboxId}, got headers: ${JSON.stringify(parsed.headers)}` -// ) -// } -// ) -// }) - describe('maskRequestHost option', () => { sandboxTest.scoped({ sandboxOpts: { diff --git a/packages/python-sdk/e2b/__init__.py b/packages/python-sdk/e2b/__init__.py index ffc9800bbe..0b8dc71c9d 100644 --- a/packages/python-sdk/e2b/__init__.py +++ b/packages/python-sdk/e2b/__init__.py @@ -87,7 +87,6 @@ SandboxNetworkSelector, SandboxNetworkSelectorContext, SandboxNetworkTransform, - SandboxNetworkTransformContext, SandboxQuery, SandboxState, SnapshotInfo, @@ -198,7 +197,6 @@ "SandboxNetworkRuleInfo", "SandboxNetworkRules", "SandboxNetworkTransform", - "SandboxNetworkTransformContext", "SandboxLifecycle", "ALL_TRAFFIC", # Snapshot diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index 8c2b9ecdcb..168838e51e 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -73,53 +73,14 @@ class SandboxNetworkTransform(TypedDict): """ -@dataclass(frozen=True) -class _SandboxNetworkTransformIdentity: - """Identity-related placeholders.""" - - jwt: str - """Placeholder ``"${e2b.identity.jwt}"``.""" - - -@dataclass(frozen=True) -class SandboxNetworkTransformContext: - """ - Context passed to a :class:`SandboxNetworkRule` ``transform`` callable. - Each field is a literal placeholder string (e.g. ``"${e2b.sandbox_id}"``) - that the proxy resolves per request at egress time. - """ - - sandbox_id: str - """Placeholder ``"${e2b.sandboxId}"``.""" - - team_id: str - """Placeholder ``"${e2b.teamId}"``.""" - - execution_id: str - """Placeholder ``"${e2b.executionId}"``.""" - - identity: _SandboxNetworkTransformIdentity - """Identity-related placeholders.""" - - -SandboxNetworkTransformResolver = Callable[ - [SandboxNetworkTransformContext], SandboxNetworkTransform -] - - class SandboxNetworkRule(TypedDict): """ Per-domain rule applied to egress requests. """ - transform: NotRequired[ - Union[SandboxNetworkTransform, SandboxNetworkTransformResolver] - ] + transform: NotRequired[SandboxNetworkTransform] """ - Transform applied to requests matching this rule. Accepts either a static - :class:`SandboxNetworkTransform` or a callable that receives a - :class:`SandboxNetworkTransformContext` of placeholder strings; the - resolved object is sent to the API as-is. + Transform applied to requests matching this rule. """ @@ -286,14 +247,6 @@ def _resolve_network_selector( return list(selector) -_TRANSFORM_CONTEXT = SandboxNetworkTransformContext( - sandbox_id="${e2b.sandboxId}", - team_id="${e2b.teamId}", - execution_id="${e2b.executionId}", - identity=_SandboxNetworkTransformIdentity(jwt="${e2b.identity.jwt}"), -) - - def _build_client_rules(rules: SandboxNetworkRules) -> SandboxNetworkConfigRules: client_rules = SandboxNetworkConfigRules() for host, host_rules in rules.items(): @@ -303,8 +256,6 @@ def _build_client_rules(rules: SandboxNetworkRules) -> SandboxNetworkConfigRules if transform is None: converted.append(ClientSandboxNetworkRule()) continue - if callable(transform): - transform = transform(_TRANSFORM_CONTEXT) client_transform = ClientSandboxNetworkTransform() headers = transform.get("headers") diff --git a/packages/python-sdk/tests/async/sandbox_async/test_network.py b/packages/python-sdk/tests/async/sandbox_async/test_network.py index d8c0c1d250..92227a4218 100644 --- a/packages/python-sdk/tests/async/sandbox_async/test_network.py +++ b/packages/python-sdk/tests/async/sandbox_async/test_network.py @@ -191,35 +191,6 @@ async def test_firewall_transform_injects_headers(async_sandbox_factory): ) -# @pytest.mark.skip_debug() -# async def test_transform_callback_resolves_sandbox_id(async_sandbox_factory): -# """The transform callback's sandbox_id placeholder is resolved by the proxy.""" -# header_name = "X-E2B-Sandbox-Id" -# -# network: SandboxNetworkOpts = { -# "rules": { -# "httpbin.e2b.team": [ -# { -# "transform": lambda ctx: {"headers": {header_name: ctx.sandbox_id}}, -# }, -# ], -# }, -# } -# async_sandbox = await async_sandbox_factory(network=network) -# -# result = await async_sandbox.commands.run( -# "curl -sS --max-time 10 https://httpbin.e2b.team/headers" -# ) -# assert result.exit_code == 0 -# -# parsed = json.loads(result.stdout) -# reflected = parsed["headers"].get(header_name) -# assert reflected == async_sandbox.sandbox_id, ( -# f"expected httpbin to reflect {header_name}={async_sandbox.sandbox_id}, " -# f"got headers: {parsed['headers']}" -# ) - - @pytest.mark.skip_debug() async def test_mask_request_host(async_sandbox_factory): """Test that mask_request_host modifies the Host header correctly.""" diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_network.py b/packages/python-sdk/tests/sync/sandbox_sync/test_network.py index 29c51cc76e..7edfae5c5f 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_network.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_network.py @@ -189,35 +189,6 @@ def test_firewall_transform_injects_headers(sandbox_factory): ) -# @pytest.mark.skip_debug() -# def test_transform_callback_resolves_sandbox_id(sandbox_factory): -# """The transform callback's sandbox_id placeholder is resolved by the proxy.""" -# header_name = "X-E2B-Sandbox-Id" -# -# network: SandboxNetworkOpts = { -# "rules": { -# "httpbin.e2b.team": [ -# { -# "transform": lambda ctx: {"headers": {header_name: ctx.sandbox_id}}, -# }, -# ], -# }, -# } -# sandbox = sandbox_factory(network=network) -# -# result = sandbox.commands.run( -# "curl -sS --max-time 10 https://httpbin.e2b.team/headers" -# ) -# assert result.exit_code == 0 -# -# parsed = json.loads(result.stdout) -# reflected = parsed["headers"].get(header_name) -# assert reflected == sandbox.sandbox_id, ( -# f"expected httpbin to reflect {header_name}={sandbox.sandbox_id}, " -# f"got headers: {parsed['headers']}" -# ) - - @pytest.mark.skip_debug() def test_mask_request_host(sandbox_factory): """Test that mask_request_host modifies the Host header correctly.""" From 156c0a9c5962be191fd2adf2e275b2788285d0b2 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Tue, 19 May 2026 12:45:33 +0200 Subject: [PATCH 23/23] Revert "refactor(sdk): drop transform callback + placeholder context" This reverts commit 0dec3afba3562ba12cbaa19f16f5a1afa0d8239e. --- packages/js-sdk/src/index.ts | 1 + packages/js-sdk/src/sandbox/sandboxApi.ts | 47 ++++++++++++++-- packages/js-sdk/tests/sandbox/network.test.ts | 39 ++++++++++++++ packages/python-sdk/e2b/__init__.py | 2 + .../python-sdk/e2b/sandbox/sandbox_api.py | 53 ++++++++++++++++++- .../tests/async/sandbox_async/test_network.py | 29 ++++++++++ .../tests/sync/sandbox_sync/test_network.py | 29 ++++++++++ 7 files changed, 193 insertions(+), 7 deletions(-) diff --git a/packages/js-sdk/src/index.ts b/packages/js-sdk/src/index.ts index 74f29105b7..5111767003 100644 --- a/packages/js-sdk/src/index.ts +++ b/packages/js-sdk/src/index.ts @@ -65,6 +65,7 @@ export type { SandboxNetworkRuleInfo, SandboxNetworkRules, SandboxNetworkTransform, + SandboxNetworkTransformContext, SandboxLifecycle, SandboxInfoLifecycle, SnapshotInfo, diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index eebf663e26..60a22b1739 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -45,14 +45,37 @@ export type SandboxNetworkTransform = { headers?: Record } +/** + * Context passed to a {@link SandboxNetworkRule} `transform` callback. Each + * field is a literal placeholder string (e.g. `'${e2b.sandboxId}'`) that the + * proxy resolves per request at egress time. + */ +export type SandboxNetworkTransformContext = { + /** Placeholder `'${e2b.sandboxId}'`. */ + readonly sandboxId: string + /** Placeholder `'${e2b.teamId}'`. */ + readonly teamId: string + /** Placeholder `'${e2b.executionId}'`. */ + readonly executionId: string + /** Identity-related placeholders. */ + readonly identity: { + /** Placeholder `'${e2b.identity.jwt}'`. */ + readonly jwt: string + } +} + /** * Per-domain rule applied to egress requests. */ export type SandboxNetworkRule = { /** - * Transform applied to requests matching this rule. + * Transform applied to requests matching this rule. Accepts either a static + * object or a callback that receives a {@link SandboxNetworkTransformContext} + * of placeholder strings; the resolved object is sent to the API as-is. */ - transform?: SandboxNetworkTransform + transform?: + | SandboxNetworkTransform + | ((ctx: SandboxNetworkTransformContext) => SandboxNetworkTransform) } /** @@ -526,14 +549,28 @@ function resolveNetworkSelector( return selector } +const TRANSFORM_CONTEXT: SandboxNetworkTransformContext = Object.freeze({ + sandboxId: '${e2b.sandboxId}', + teamId: '${e2b.teamId}', + executionId: '${e2b.executionId}', + identity: Object.freeze({ + jwt: '${e2b.identity.jwt}', + }), +}) + function resolveRulesForBody( rules: Map ): Record { const out: Record = {} for (const [host, hostRules] of rules) { - out[host] = hostRules.map((rule) => - rule.transform === undefined ? {} : { transform: rule.transform } - ) + out[host] = hostRules.map((rule) => { + if (rule.transform === undefined) return {} + const transform = + typeof rule.transform === 'function' + ? rule.transform(TRANSFORM_CONTEXT) + : rule.transform + return { transform } + }) } return out } diff --git a/packages/js-sdk/tests/sandbox/network.test.ts b/packages/js-sdk/tests/sandbox/network.test.ts index 7388d98048..08f01be91e 100644 --- a/packages/js-sdk/tests/sandbox/network.test.ts +++ b/packages/js-sdk/tests/sandbox/network.test.ts @@ -236,6 +236,45 @@ describe('firewall transform injects headers', () => { ) }) +// describe('transform callback resolves sandboxId placeholder', () => { +// const headerName = 'X-E2B-Sandbox-Id' +// +// sandboxTest.scoped({ +// sandboxOpts: { +// network: { +// rules: { +// 'httpbin.e2b.team': [ +// { +// transform: ({ sandboxId }) => ({ +// headers: { [headerName]: sandboxId }, +// }), +// }, +// ], +// }, +// }, +// }, +// }) +// +// sandboxTest.skipIf(isDebug)( +// 'placeholder is replaced with the actual sandbox id', +// async ({ sandbox }) => { +// const result = await sandbox.commands.run( +// 'curl -sS --max-time 10 https://httpbin.e2b.team/headers' +// ) +// assert.equal(result.exitCode, 0) +// +// const parsed = JSON.parse(result.stdout) as { +// headers: Record +// } +// assert.equal( +// parsed.headers[headerName], +// sandbox.sandboxId, +// `expected httpbin to reflect ${headerName}=${sandbox.sandboxId}, got headers: ${JSON.stringify(parsed.headers)}` +// ) +// } +// ) +// }) + describe('maskRequestHost option', () => { sandboxTest.scoped({ sandboxOpts: { diff --git a/packages/python-sdk/e2b/__init__.py b/packages/python-sdk/e2b/__init__.py index 0b8dc71c9d..ffc9800bbe 100644 --- a/packages/python-sdk/e2b/__init__.py +++ b/packages/python-sdk/e2b/__init__.py @@ -87,6 +87,7 @@ SandboxNetworkSelector, SandboxNetworkSelectorContext, SandboxNetworkTransform, + SandboxNetworkTransformContext, SandboxQuery, SandboxState, SnapshotInfo, @@ -197,6 +198,7 @@ "SandboxNetworkRuleInfo", "SandboxNetworkRules", "SandboxNetworkTransform", + "SandboxNetworkTransformContext", "SandboxLifecycle", "ALL_TRAFFIC", # Snapshot diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index 168838e51e..8c2b9ecdcb 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -73,14 +73,53 @@ class SandboxNetworkTransform(TypedDict): """ +@dataclass(frozen=True) +class _SandboxNetworkTransformIdentity: + """Identity-related placeholders.""" + + jwt: str + """Placeholder ``"${e2b.identity.jwt}"``.""" + + +@dataclass(frozen=True) +class SandboxNetworkTransformContext: + """ + Context passed to a :class:`SandboxNetworkRule` ``transform`` callable. + Each field is a literal placeholder string (e.g. ``"${e2b.sandbox_id}"``) + that the proxy resolves per request at egress time. + """ + + sandbox_id: str + """Placeholder ``"${e2b.sandboxId}"``.""" + + team_id: str + """Placeholder ``"${e2b.teamId}"``.""" + + execution_id: str + """Placeholder ``"${e2b.executionId}"``.""" + + identity: _SandboxNetworkTransformIdentity + """Identity-related placeholders.""" + + +SandboxNetworkTransformResolver = Callable[ + [SandboxNetworkTransformContext], SandboxNetworkTransform +] + + class SandboxNetworkRule(TypedDict): """ Per-domain rule applied to egress requests. """ - transform: NotRequired[SandboxNetworkTransform] + transform: NotRequired[ + Union[SandboxNetworkTransform, SandboxNetworkTransformResolver] + ] """ - Transform applied to requests matching this rule. + Transform applied to requests matching this rule. Accepts either a static + :class:`SandboxNetworkTransform` or a callable that receives a + :class:`SandboxNetworkTransformContext` of placeholder strings; the + resolved object is sent to the API as-is. """ @@ -247,6 +286,14 @@ def _resolve_network_selector( return list(selector) +_TRANSFORM_CONTEXT = SandboxNetworkTransformContext( + sandbox_id="${e2b.sandboxId}", + team_id="${e2b.teamId}", + execution_id="${e2b.executionId}", + identity=_SandboxNetworkTransformIdentity(jwt="${e2b.identity.jwt}"), +) + + def _build_client_rules(rules: SandboxNetworkRules) -> SandboxNetworkConfigRules: client_rules = SandboxNetworkConfigRules() for host, host_rules in rules.items(): @@ -256,6 +303,8 @@ def _build_client_rules(rules: SandboxNetworkRules) -> SandboxNetworkConfigRules if transform is None: converted.append(ClientSandboxNetworkRule()) continue + if callable(transform): + transform = transform(_TRANSFORM_CONTEXT) client_transform = ClientSandboxNetworkTransform() headers = transform.get("headers") diff --git a/packages/python-sdk/tests/async/sandbox_async/test_network.py b/packages/python-sdk/tests/async/sandbox_async/test_network.py index 92227a4218..d8c0c1d250 100644 --- a/packages/python-sdk/tests/async/sandbox_async/test_network.py +++ b/packages/python-sdk/tests/async/sandbox_async/test_network.py @@ -191,6 +191,35 @@ async def test_firewall_transform_injects_headers(async_sandbox_factory): ) +# @pytest.mark.skip_debug() +# async def test_transform_callback_resolves_sandbox_id(async_sandbox_factory): +# """The transform callback's sandbox_id placeholder is resolved by the proxy.""" +# header_name = "X-E2B-Sandbox-Id" +# +# network: SandboxNetworkOpts = { +# "rules": { +# "httpbin.e2b.team": [ +# { +# "transform": lambda ctx: {"headers": {header_name: ctx.sandbox_id}}, +# }, +# ], +# }, +# } +# async_sandbox = await async_sandbox_factory(network=network) +# +# result = await async_sandbox.commands.run( +# "curl -sS --max-time 10 https://httpbin.e2b.team/headers" +# ) +# assert result.exit_code == 0 +# +# parsed = json.loads(result.stdout) +# reflected = parsed["headers"].get(header_name) +# assert reflected == async_sandbox.sandbox_id, ( +# f"expected httpbin to reflect {header_name}={async_sandbox.sandbox_id}, " +# f"got headers: {parsed['headers']}" +# ) + + @pytest.mark.skip_debug() async def test_mask_request_host(async_sandbox_factory): """Test that mask_request_host modifies the Host header correctly.""" diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_network.py b/packages/python-sdk/tests/sync/sandbox_sync/test_network.py index 7edfae5c5f..29c51cc76e 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_network.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_network.py @@ -189,6 +189,35 @@ def test_firewall_transform_injects_headers(sandbox_factory): ) +# @pytest.mark.skip_debug() +# def test_transform_callback_resolves_sandbox_id(sandbox_factory): +# """The transform callback's sandbox_id placeholder is resolved by the proxy.""" +# header_name = "X-E2B-Sandbox-Id" +# +# network: SandboxNetworkOpts = { +# "rules": { +# "httpbin.e2b.team": [ +# { +# "transform": lambda ctx: {"headers": {header_name: ctx.sandbox_id}}, +# }, +# ], +# }, +# } +# sandbox = sandbox_factory(network=network) +# +# result = sandbox.commands.run( +# "curl -sS --max-time 10 https://httpbin.e2b.team/headers" +# ) +# assert result.exit_code == 0 +# +# parsed = json.loads(result.stdout) +# reflected = parsed["headers"].get(header_name) +# assert reflected == sandbox.sandbox_id, ( +# f"expected httpbin to reflect {header_name}={sandbox.sandbox_id}, " +# f"got headers: {parsed['headers']}" +# ) + + @pytest.mark.skip_debug() def test_mask_request_host(sandbox_factory): """Test that mask_request_host modifies the Host header correctly."""