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 diff --git a/packages/js-sdk/src/api/schema.gen.ts b/packages/js-sdk/src/api/schema.gen.ts index d3c42e3ee2..d067e74062 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; @@ -2279,17 +2334,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. */ + /** @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 or IP addresses 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 3a188e4d85..036d112e47 100644 --- a/packages/js-sdk/src/index.ts +++ b/packages/js-sdk/src/index.ts @@ -57,6 +57,13 @@ export type { SandboxListOpts, SandboxPaginator, SandboxNetworkOpts, + SandboxNetworkInfo, + SandboxNetworkSelector, + SandboxNetworkSelectorContext, + SandboxNetworkRule, + SandboxNetworkRuleInfo, + SandboxNetworkRules, + SandboxNetworkTransform, SandboxLifecycle, SandboxInfoLifecycle, SnapshotInfo, diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index cae217b64b..d94d90ca1a 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 { InvalidArgumentError, SandboxNotFoundError, @@ -37,23 +38,118 @@ export type GitHubMcpServer = { } } +/** + * Transform applied to egress requests matching a {@link SandboxNetworkRule}. + */ +export type SandboxNetworkTransform = { + /** + * Headers to inject into the outbound request. Values override any headers + * already present on the request. + */ + headers?: Record +} + +/** + * Per-domain rule applied to egress requests. + */ +export type SandboxNetworkRule = { + /** + * Transform applied to requests matching this rule. + */ + transform?: SandboxNetworkTransform +} + +/** + * Map of host (or CIDR / IP) to ordered list of rules applied to outbound + * 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 + | 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. + */ +export type SandboxNetworkSelectorContext = { + /** 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 `{ allTraffic, rules }` 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. * + * Accepts either a static array of CIDR blocks, IP addresses, or hostnames, + * 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: - * - To allow 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"]` + * - Allow only rule-registered hosts: + * `({ rules }) => [...rules.keys()]` */ - allowOut?: string[] + 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: `({ allTraffic }) => [allTraffic]` */ - denyOut?: string[] + 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. @@ -69,6 +165,19 @@ 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[] + rules?: Record + allowPublicTraffic?: boolean + maskRequestHost?: string +} + export type SandboxLifecycle = { /** * Action to take when sandbox timeout is reached. @@ -360,7 +469,7 @@ export interface SandboxInfo { /** * Sandbox network configuration. */ - network?: SandboxNetworkOpts + network?: SandboxNetworkInfo /** * Sandbox lifecycle configuration. @@ -413,6 +522,62 @@ export interface SandboxMetrics { diskTotal: number } +function resolveNetworkSelector( + selector: SandboxNetworkSelector | undefined, + rules: Map +): string[] | undefined { + if (selector === undefined) { + return undefined + } + + if (typeof selector === 'function') { + return selector({ allTraffic: ALL_TRAFFIC, rules }) + } + + return selector +} + +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 } + ) + } + return out +} + +function buildNetworkBody( + network: SandboxNetworkOpts | undefined +): components['schemas']['SandboxNetworkConfig'] | undefined { + if (!network) { + return undefined + } + + 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: resolveRulesForBody(rules) } + : {}), + ...(network.allowPublicTraffic !== undefined + ? { allowPublicTraffic: network.allowPublicTraffic } + : {}), + ...(network.maskRequestHost !== undefined + ? { maskRequestHost: network.maskRequestHost } + : {}), + } +} + export class SandboxApi { protected constructor() {} @@ -605,6 +770,7 @@ 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, } @@ -786,7 +952,7 @@ export class SandboxApi { timeout: timeoutToSeconds(timeoutMs), secure: opts?.secure ?? true, allow_internet_access: opts?.allowInternetAccess ?? true, - network: opts?.network, + network: buildNetworkBody(opts?.network), autoPause: onTimeout === 'pause', autoResume: { enabled: autoResume }, } diff --git a/packages/js-sdk/tests/sandbox/network.test.ts b/packages/js-sdk/tests/sandbox/network.test.ts index b6d2ebe23c..7388d98048 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: ({ allTraffic }) => [allTraffic], 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 allTraffic selector', () => { sandboxTest.scoped({ sandboxOpts: { network: { - denyOut: [ALL_TRAFFIC], + denyOut: ({ allTraffic }) => [allTraffic], }, }, }) sandboxTest.skipIf(isDebug)( - 'deny all traffic using allTraffic helper', + '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: [ALL_TRAFFIC], + denyOut: ({ allTraffic }) => [allTraffic], allowOut: ['1.1.1.1', '8.8.8.8'], }, }, @@ -193,6 +193,49 @@ describe('allowPublicTraffic=true', () => { ) }) +describe('firewall transform injects headers', () => { + const injectedHeader = 'X-E2B-Test-Token' + const injectedValue = 'e2b-transform-value-123' + + sandboxTest.scoped({ + sandboxOpts: { + network: { + rules: { + 'httpbin.e2b.team': [ + { + transform: { + headers: { + [injectedHeader]: injectedValue, + }, + }, + }, + ], + }, + }, + }, + }) + + sandboxTest.skipIf(isDebug)( + 'injected header is reflected by httpbin.e2b.team/headers', + 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 + } + 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/__init__.py b/packages/python-sdk/e2b/__init__.py index e43f621e41..0b8dc71c9d 100644 --- a/packages/python-sdk/e2b/__init__.py +++ b/packages/python-sdk/e2b/__init__.py @@ -79,7 +79,14 @@ SandboxInfoLifecycle, SandboxMetrics, SandboxLifecycle, + SandboxNetworkInfo, SandboxNetworkOpts, + SandboxNetworkRule, + SandboxNetworkRuleInfo, + SandboxNetworkRules, + SandboxNetworkSelector, + SandboxNetworkSelectorContext, + SandboxNetworkTransform, SandboxQuery, SandboxState, SnapshotInfo, @@ -183,6 +190,13 @@ "FileType", # Network "SandboxNetworkOpts", + "SandboxNetworkInfo", + "SandboxNetworkSelector", + "SandboxNetworkSelectorContext", + "SandboxNetworkRule", + "SandboxNetworkRuleInfo", + "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 fb263334ff..365bfe9a29 100644 --- a/packages/python-sdk/e2b/api/client/models/__init__.py +++ b/packages/python-sdk/e2b/api/client/models/__init__.py @@ -42,6 +42,10 @@ 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 @@ -54,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 @@ -125,6 +133,8 @@ "PostSandboxesSandboxIDRefreshesBody", "PostSandboxesSandboxIDSnapshotsBody", "PostSandboxesSandboxIDTimeoutBody", + "PutSandboxesSandboxIDNetworkBody", + "PutSandboxesSandboxIDNetworkBodyRules", "ResumedSandbox", "Sandbox", "SandboxAutoResumeConfig", @@ -138,6 +148,10 @@ "SandboxLogsV2Response", "SandboxMetric", "SandboxNetworkConfig", + "SandboxNetworkConfigRules", + "SandboxNetworkRule", + "SandboxNetworkTransform", + "SandboxNetworkTransformHeaders", "SandboxOnTimeout", "SandboxState", "SandboxVolumeMount", 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/put_sandboxes_sandbox_id_network_body_rules.py b/packages/python-sdk/e2b/api/client/models/put_sandboxes_sandbox_id_network_body_rules.py new file mode 100644 index 0000000000..1af75b3cb3 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/put_sandboxes_sandbox_id_network_body_rules.py @@ -0,0 +1,71 @@ +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="PutSandboxesSandboxIDNetworkBodyRules") + + +@_attrs_define +class PutSandboxesSandboxIDNetworkBodyRules: + """Per-domain transform rules. Replaces all existing rules when provided.""" + + 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) + 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 = SandboxNetworkRule.from_dict( + additional_property_item_data + ) + + additional_property.append(additional_property_item) + + additional_properties[prop_name] = additional_property + + 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["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_network_config.py b/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py index 08284c792f..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 or IP addresses for egress traffic. Allowed - addresses always take precedence over blocked addresses. + 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 or IP addresses 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_network_rule.py b/packages/python-sdk/e2b/api/client/models/sandbox_network_rule.py new file mode 100644 index 0000000000..f1d0a306ec --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_network_rule.py @@ -0,0 +1,74 @@ +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_transform import SandboxNetworkTransform + + +T = TypeVar("T", bound="SandboxNetworkRule") + + +@_attrs_define +class SandboxNetworkRule: + """Transform rule applied to egress requests matching a domain pattern. + + Attributes: + transform (Union[Unset, SandboxNetworkTransform]): Transformations applied to matching egress requests before + forwarding. + """ + + transform: Union[Unset, "SandboxNetworkTransform"] = 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_network_transform import SandboxNetworkTransform + + d = dict(src_dict) + _transform = d.pop("transform", UNSET) + transform: Union[Unset, SandboxNetworkTransform] + if isinstance(_transform, Unset): + transform = UNSET + else: + transform = SandboxNetworkTransform.from_dict(_transform) + + sandbox_network_rule = cls( + 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_transform.py b/packages/python-sdk/e2b/api/client/models/sandbox_network_transform.py new file mode 100644 index 0000000000..d754240c60 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_network_transform.py @@ -0,0 +1,79 @@ +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_transform_headers import ( + SandboxNetworkTransformHeaders, + ) + + +T = TypeVar("T", bound="SandboxNetworkTransform") + + +@_attrs_define +class SandboxNetworkTransform: + """Transformations applied to matching egress requests before forwarding. + + Attributes: + 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, "SandboxNetworkTransformHeaders"] = 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_transform_headers import ( + SandboxNetworkTransformHeaders, + ) + + d = dict(src_dict) + _headers = d.pop("headers", UNSET) + headers: Union[Unset, SandboxNetworkTransformHeaders] + if isinstance(_headers, Unset): + headers = UNSET + else: + headers = SandboxNetworkTransformHeaders.from_dict(_headers) + + sandbox_network_transform = cls( + headers=headers, + ) + + sandbox_network_transform.additional_properties = d + return sandbox_network_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_transform_headers.py b/packages/python-sdk/e2b/api/client/models/sandbox_network_transform_headers.py new file mode 100644 index 0000000000..c2d7c1aaa2 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_network_transform_headers.py @@ -0,0 +1,47 @@ +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="SandboxNetworkTransformHeaders") + + +@_attrs_define +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) + + 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_transform_headers = cls() + + sandbox_network_transform_headers.additional_properties = d + return sandbox_network_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/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index c3a07afc13..32f9889ff4 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, 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 @@ -15,9 +26,22 @@ from e2b.api.client.models import ( SandboxNetworkConfig as ClientSandboxNetworkConfig, ) +from e2b.api.client.models import ( + SandboxNetworkConfigRules, +) +from e2b.api.client.models import ( + SandboxNetworkRule as ClientSandboxNetworkRule, +) +from e2b.api.client.models import ( + SandboxNetworkTransform as ClientSandboxNetworkTransform, +) +from e2b.api.client.models import ( + SandboxNetworkTransformHeaders as ClientSandboxNetworkTransformHeaders, +) 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): @@ -48,26 +72,112 @@ class GitHubMcpServerConfig(TypedDict): McpServer = Union[BaseMcpServer, GitHubMcpServer] +class SandboxNetworkTransform(TypedDict): + """ + Transform applied to egress requests matching a :class:`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): + """ + Per-domain rule applied to egress requests. + """ + + transform: NotRequired[SandboxNetworkTransform] + """ + Transform applied to requests matching this rule. + """ + + +SandboxNetworkRules = Dict[str, List[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 ``SandboxNetworkOpts.allow_out``. +""" + + +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: + """ + Context passed to ``allow_out``/``deny_out`` callables. + """ + + all_traffic: str + """All traffic sentinel — equivalent to ``"0.0.0.0/0"``.""" + + rules: Mapping[str, List[SandboxNetworkRule]] + """Rules registered in :attr:`SandboxNetworkOpts.rules`.""" + + +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): """ Sandbox network configuration options. """ - allow_out: NotRequired[List[str]] + 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. + + Accepts either a static list of CIDR blocks / IP addresses / hostnames, or + a callable that receives a :class:`SandboxNetworkSelectorContext` and + returns the same. ``ctx.all_traffic`` is ``"0.0.0.0/0"``; ``ctx.rules`` is + a read-only view of :attr:`rules`. Examples: - - To allow traffic to specific addresses: `["1.1.1.1", "8.8.8.0/24"]` + - Static list: ``["1.1.1.1", "8.8.8.0/24"]`` + - Allow only rule-registered hosts: + ``lambda ctx: list(ctx.rules.keys())`` """ - 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_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] @@ -86,6 +196,20 @@ 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] + rules: Dict[str, List[SandboxNetworkRuleInfo]] + allow_public_traffic: bool + mask_request_host: str + + class SandboxLifecycle(TypedDict): """ Sandbox lifecycle configuration; defines post-timeout behavior and auto-resume settings. @@ -120,18 +244,85 @@ class SandboxInfoLifecycle(TypedDict): """ +def _resolve_network_selector( + selector: Optional[SandboxNetworkSelector], + rules: Mapping[str, List[SandboxNetworkRule]], +) -> Optional[List[str]]: + if selector is None: + return None + + if callable(selector): + 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], +) -> Optional[Dict[str, Any]]: + """Resolve a :class:`SandboxNetworkOpts` into the dict the API expects.""" + if network is None: + return None + + 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: + body["mask_request_host"] = network["mask_request_host"] + + return body + + def from_client_network_config( network: Union[Unset, ClientSandboxNetworkConfig], -) -> Optional[SandboxNetworkOpts]: +) -> Optional[SandboxNetworkInfo]: if isinstance(network, Unset): return None - result: SandboxNetworkOpts = {} + result: SandboxNetworkInfo = {} if not isinstance(network.allow_out, Unset): 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( + 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): @@ -184,7 +375,7 @@ 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.""" lifecycle: Optional[SandboxInfoLifecycle] = None """Sandbox lifecycle configuration.""" @@ -198,7 +389,7 @@ 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, lifecycle: Optional[SandboxInfoLifecycle] = None, ): return cls( diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 4aa7a9f6ac..11a98923df 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -193,7 +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 + :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 diff --git a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py index 78c87feece..55085293a8 100644 --- a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py @@ -45,6 +45,7 @@ SandboxNetworkOpts, SandboxQuery, SnapshotInfo, + build_network_config, ) from e2b.sandbox_async.paginator import AsyncSandboxPaginator @@ -184,6 +185,7 @@ async def _create_sandbox( "auto_resume can only be True when the resolved on_timeout is 'pause'." ) + network_body = build_network_config(network) body = NewSandbox( template_id=template, auto_pause=on_timeout == "pause", @@ -194,7 +196,7 @@ 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, volume_mounts=volume_mounts if volume_mounts else UNSET, ) diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index 3557f1d141..f00d1b71ce 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -191,7 +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 + :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 diff --git a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py index c84bae72a4..b8b923a22d 100644 --- a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py @@ -37,13 +37,14 @@ ) from e2b.sandbox.main import SandboxBase from e2b.sandbox.sandbox_api import ( - SandboxLifecycle, McpServer, SandboxInfo, + SandboxLifecycle, SandboxMetrics, SandboxNetworkOpts, SandboxQuery, SnapshotInfo, + build_network_config, ) from e2b.sandbox_sync.paginator import SandboxPaginator, get_api_client @@ -183,6 +184,7 @@ def _create_sandbox( "auto_resume can only be True when the resolved on_timeout is 'pause'." ) + network_body = build_network_config(network) body = NewSandbox( template_id=template, auto_pause=on_timeout == "pause", @@ -193,7 +195,7 @@ 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, volume_mounts=volume_mounts if volume_mounts else 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..92227a4218 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,8 @@ +import json + import pytest -from e2b import ALL_TRAFFIC, SandboxNetworkOpts +from e2b import SandboxNetworkOpts from e2b.sandbox.commands.command_handle import CommandExitException @@ -8,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_traffic], allow_out=["1.1.1.1"] + ) ) # Test that allowed IP works @@ -50,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_traffic selector.""" async_sandbox = await async_sandbox_factory( - network=SandboxNetworkOpts(deny_out=[ALL_TRAFFIC]), timeout=30 + network=SandboxNetworkOpts(deny_out=lambda ctx: [ctx.all_traffic]), timeout=30 ) # Test that all traffic is denied @@ -74,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_traffic], allow_out=["1.1.1.1", "8.8.8.8"] ) ) @@ -159,6 +163,34 @@ async def test_allow_public_traffic_true(async_sandbox_factory): assert response.status_code == 200 +@pytest.mark.skip_debug() +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 = { + "rules": { + "httpbin.e2b.team": [ + {"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.e2b.team/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..7edfae5c5f 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,8 @@ +import json + import pytest -from e2b import ALL_TRAFFIC, SandboxNetworkOpts +from e2b import SandboxNetworkOpts from e2b.sandbox.commands.command_handle import CommandExitException @@ -8,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_traffic], allow_out=["1.1.1.1"] + ) ) # Test that allowed IP works @@ -48,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_traffic selector.""" sandbox = sandbox_factory( - network=SandboxNetworkOpts(deny_out=[ALL_TRAFFIC]), timeout=30 + network=SandboxNetworkOpts(deny_out=lambda ctx: [ctx.all_traffic]), timeout=30 ) # Test that all traffic is denied @@ -72,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_traffic], allow_out=["1.1.1.1", "8.8.8.8"] ) ) @@ -157,6 +161,34 @@ def test_allow_public_traffic_true(sandbox_factory): assert response.status_code == 200 +@pytest.mark.skip_debug() +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 = { + "rules": { + "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.e2b.team/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 87084f64b9..464efe67c6 100644 --- a/spec/openapi.yml +++ b/spec/openapi.yml @@ -271,17 +271,41 @@ 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 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 + 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 @@ -1799,8 +1823,8 @@ paths: get: description: Health check responses: - '200': - description: Request was successful + '204': + description: The service is healthy '401': $ref: '#/components/responses/401' @@ -2360,6 +2384,57 @@ paths: '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: description: Refresh the sandbox extending its time to live