Skip to content

feat(sdk): resolve iam token placeholders in network transform callbacks - #1616

Merged
mishushakov merged 7 commits into
iam-sdk-featurefrom
network-transform-callback
Aug 13, 2026
Merged

feat(sdk): resolve iam token placeholders in network transform callbacks#1616
mishushakov merged 7 commits into
iam-sdk-featurefrom
network-transform-callback

Conversation

@mishushakov

@mishushakov mishushakov commented Jul 27, 2026

Copy link
Copy Markdown
Member

Stacked on #1606 (iam-sdk-feature) — merge that one first. This is the second half of SDK-245: it makes the workload tokens registered by Sandbox.create's iam option usable, by letting a network rule's transform be a callback that receives placeholder strings the egress proxy resolves per request.

iam.tokens.aws is the literal string ${e2b.identity.tokens.aws} (the frozen backend spelling — a placeholder can only select a persisted named token, never an inline audience or claim). The SDK never resolves it: the wire payload carries the placeholder and the proxy substitutes a freshly minted JWT-SVID when it forwards the request, so the token value never reaches SDK-side code or the sandbox.

Referencing a name that isn't registered in iam.tokens fails with InvalidArgumentError / InvalidArgumentException listing the names that are — the proxy never turns an unregistered name into a token, so a typo would otherwise surface as a confusing auth failure at the destination. updateNetwork / update_network accepts the same callbacks, but its payload carries no iam config, so token names can't be validated client-side there and any name resolves to its placeholder.

Static transform: { headers } objects keep working unchanged (including hand-written ${e2b.identity.tokens.<name>} strings, which stay the escape hatch for tokens the SDK doesn't know about).

Only { iam } is exposed on the context for now — ${e2b.sandboxId} / ${e2b.teamId} / ${e2b.executionId} from the older prototype are not part of the current backend design, so sandbox can be added later when there is something to resolve.

Usage

import { Sandbox, Secret } from 'e2b'

const sandbox = await Sandbox.create({
  iam: {
    tokens: {
      aws: Secret.iamToken({ audience: 'sts.amazonaws.com', tokenType: 'JWT-SVID' }),
    },
  },
  network: {
    // Only allow egress to hosts that have rules registered.
    allowOut: ({ rules }) => [...rules.keys()],
    rules: {
      'api.internal.example.com': [
        {
          transform: ({ iam }) => ({
            headers: { Authorization: `Bearer ${iam.tokens.aws}` },
          }),
        },
      ],
    },
  },
})
from e2b import Sandbox, Secret

sandbox = Sandbox.create(
    iam={
        "tokens": {
            "aws": Secret.iam_token(audience="sts.amazonaws.com", token_type="JWT-SVID"),
        },
    },
    network={
        "allow_out": lambda ctx: list(ctx.rules.keys()),
        "rules": {
            "api.internal.example.com": [
                {
                    "transform": lambda ctx: {
                        "headers": {"Authorization": f"Bearer {ctx.iam.tokens['aws']}"},
                    },
                },
            ],
        },
    },
)

Both send:

{
  "iam": { "tokens": { "aws": { "audience": "sts.amazonaws.com", "tokenType": "JWT-SVID" } } },
  "network": {
    "allowOut": ["api.internal.example.com"],
    "rules": {
      "api.internal.example.com": [
        { "transform": { "headers": { "Authorization": "Bearer ${e2b.identity.tokens.aws}" } } }
      ]
    }
  }
}

Notes

  • allowOut / deny_out selectors run before transforms are resolved, so ctx.rules still hands back the rules you passed — a rule's transform there is the union (object or callback), not the materialized object. The getInfo view keeps its own narrowed SandboxNetworkRuleInfo type.
  • Token names are validated where they are registered and again before interpolation: a name cannot be empty or contain {, } or control characters. The proxy reads a placeholder up to its first }, so a}b would mint the unrelated token a and leave b} as literal text, and a { in a name can open a second placeholder. The interpolation check is what covers updateNetwork, where any name the callback looks up becomes a placeholder without passing through the iam config.
  • Every lookup form on iam.tokens is guarded, not just [name]: Python's map is a Mapping whose __getitem__ owns resolution (so .get('typo') raises instead of returning None), and membership ('aws' in ctx.iam.tokens / 'aws' in iam.tokens) answers "is it registered?" without raising so a callback can branch on it. Lookup checks own keys only, so an unregistered name colliding with an object member (constructor, __proto__) reports as unregistered instead of resolving to a built-in; the four properties the runtime itself reads (toJSON, then, toString, valueOf) still resolve normally, so serializing, awaiting or coercing the map does not trip the guard.
  • A callback must be synchronous and return a plain transform object; a promise (from an async callback), an array, a Map/Date/class instance, or a missing return value is rejected with an actionable error rather than silently creating a rule with no headers. The awaitable is closed/caught so you don't also get an unawaited-coroutine warning or an unhandled rejection.

Tests

New payload-level tests: JS tests/sandbox/networkTransform.test.ts (msw), Python tests/shared/sandbox/test_network_transform.py — shared rather than mirrored into the sync and async suites, since they only exercise the shared builders. They cover placeholder resolution, enumerating and membership-testing registered tokens, JSON.stringify of the context not tripping the guard, static transforms staying byte-identical, transform: null, the unregistered-name rejection through both [name] and .get(), the no-iam rejection, non-transform and async return values, unusable token names (both braces, a smuggled placeholder, a newline, empty) at registration and on the update path, and the permissive updateNetwork path.

Verified against production on all three surfaces (JS, sync Python, async Python), where:

  1. a static transform carrying Bearer ${e2b.identity.tokens.aws} is accepted by validateNetworkRules and round-trips through getInfo / get_info unchanged;
  2. the callback-resolved payload reaches the API and is answered with the expected team-gating error (400: Sandbox IAM workload tokens are not available for your team.), since iam is still feature-flagged;
  3. a misspelled or unusable token name is rejected client-side before any request is made.

Proxy-side substitution of the placeholder ships separately in belt (EN-1864); until then the header value is forwarded verbatim, which is why there is no end-to-end injection test here.

Part of SDK-245.

@linear-code

linear-code Bot commented Jul 27, 2026

Copy link
Copy Markdown

SDK-245

SDK-320

@cla-bot cla-bot Bot added the cla-signed label Jul 27, 2026
@changeset-bot

changeset-bot Bot commented Jul 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 35062ec

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
e2b Minor
@e2b/python-sdk Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@cursor

cursor Bot commented Jul 27, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes how egress auth headers are built and validated client-side for IAM placeholders; mistakes could ship unusable placeholders on update paths, but secrets still stay on the proxy.

Overview
Network rule transform can be a synchronous callback that receives iam.tokens placeholder strings (${e2b.identity.tokens.<name>} on the wire), so workload identity tokens can be referenced in egress headers without the SDK seeing minted values. On Sandbox.create, only names registered in iam.tokens resolve; typos raise InvalidArgumentError / InvalidArgumentException. Static transform objects behave as before.

updateNetwork / update_network runs the same callbacks but cannot validate names against the sandbox’s registered tokens, so any looked-up name becomes a placeholder. Token names are rejected when empty or containing {, }, or control characters, at registration and when interpolating placeholders.

Callbacks must return a plain transform object; promises and wrong return types are rejected. JS uses a proxied iam.tokens map; Python uses IamTokenPlaceholders with the same lookup and membership rules.

Reviewed by Cursor Bugbot for commit 35062ec. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Package Artifacts

Built from 42a060b. Download artifacts from this workflow run.

JS SDK (e2b@2.38.4-network-transform-callback.0):

npm install ./e2b-2.38.4-network-transform-callback.0.tgz

CLI (@e2b/cli@2.16.2-network-transform-callback.0):

npm install ./e2b-cli-2.16.2-network-transform-callback.0.tgz

Python SDK (e2b==2.38.0+network.transform.callback):

pip install ./e2b-2.38.0+network.transform.callback-py3-none-any.whl

Comment thread packages/python-sdk/e2b/sandbox/sandbox_api.py Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e206170ace

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/js-sdk/src/sandbox/sandboxApi.ts Outdated
Comment thread packages/js-sdk/src/sandbox/sandboxApi.ts Outdated
Comment thread packages/js-sdk/src/sandbox/sandboxApi.ts
Comment thread packages/python-sdk/e2b/sandbox/sandbox_api.py Outdated
mishushakov added a commit that referenced this pull request Jul 27, 2026
Review feedback on #1616:

- The token proxy gated on `prop in target`, which also matches inherited
  Object.prototype members, so an unregistered token named `constructor`
  or `__proto__` resolved to a built-in instead of being reported —
  `Bearer function Object() { [native code] }` on the wire. It now checks
  own keys, with the four properties the runtime itself reads (`toJSON`,
  `then`, `toString`, `valueOf`) still resolving normally so serializing,
  awaiting or coercing the map does not trip the guard. Python's mapping
  already treated those names as unregistered.
- The callback return check accepted any object, so a `Map`, `Date` or
  class instance was forwarded and serialized to `{}` — a rule created
  without the headers it exists for. It now requires a plain object,
  mirroring Python's `isinstance(transform, Mapping)`.

Co-Authored-By: Claude <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit f0f8f35. Configure here.

Comment thread packages/js-sdk/src/sandbox/sandboxApi.ts Outdated
Comment thread packages/js-sdk/src/sandbox/sandboxApi.ts Outdated
mishushakov and others added 7 commits August 13, 2026 16:46
A network rule's `transform` can now be a callback receiving a context of
placeholder strings, so a workload identity token registered in the `iam`
option can be injected into egress requests without the SDK ever seeing
its value: `iam.tokens.aws` resolves to the literal
`${e2b.identity.tokens.aws}`, which the egress proxy substitutes with a
freshly minted token per request.

Referencing a token that is not registered in `iam.tokens` fails with
InvalidArgumentError / InvalidArgumentException — the proxy drops
placeholders it cannot resolve, so a typo would otherwise silently strip
the header. The update-network endpoint carries no iam config, so its
token names are unknown client-side and any name resolves there.

Co-Authored-By: Claude <noreply@anthropic.com>
…e destination

The deployed egress proxy forwards header values verbatim, so an
unregistered token name reaches the destination as literal placeholder
text today and is dropped once substitution ships. Either way it never
becomes a token, which is what the client-side check is for.

Co-Authored-By: Claude <noreply@anthropic.com>
Review pass on the callback path:

- Python's placeholder map was a dict subclass hooking __missing__, which
  `.get()` never calls: `ctx.iam.tokens.get('awz')` returned None and
  shipped a literal "Bearer None" header, and on the update path `.get()`
  did that even for a correctly spelled token. It is now a Mapping whose
  __getitem__ owns both resolution and validation, with membership
  answering "is it registered?" without raising, like `in` in JS.
- The JS return-value guard accepted any object, so an async callback's
  promise serialized to `"transform": {}` — a rule created without the
  headers it exists for. Promises and arrays are now rejected, with a
  dedicated "must be synchronous" error in both SDKs; the awaitable is
  closed/caught so the caller does not also get an unawaited-coroutine
  warning or an unhandled rejection.
- JS forwarded an explicit `transform: null` as `null` while Python read
  it as no transform.
- The JS token proxy threw on the runtime's own `toJSON`/`then` probes, so
  `JSON.stringify(iam.tokens)` inside a callback aborted create.
- Document the permissive update-network path in `SandboxNetworkUpdate`
  and the changeset instead of only in an internal comment.
- Payload tests move to tests/shared/sandbox, which is what that directory
  is for — they only exercise the shared builders, so the sync and async
  copies were 173 duplicated lines.

Co-Authored-By: Claude <noreply@anthropic.com>
Review feedback on #1616:

- The token proxy gated on `prop in target`, which also matches inherited
  Object.prototype members, so an unregistered token named `constructor`
  or `__proto__` resolved to a built-in instead of being reported —
  `Bearer function Object() { [native code] }` on the wire. It now checks
  own keys, with the four properties the runtime itself reads (`toJSON`,
  `then`, `toString`, `valueOf`) still resolving normally so serializing,
  awaiting or coercing the map does not trip the guard. Python's mapping
  already treated those names as unregistered.
- The callback return check accepted any object, so a `Map`, `Date` or
  class instance was forwarded and serialized to `{}` — a rule created
  without the headers it exists for. It now requires a plain object,
  mirroring Python's `isinstance(transform, Mapping)`.

Co-Authored-By: Claude <noreply@anthropic.com>
The `get` trap moved to own keys in the previous commit, but `in` still
went through the default `has`, which walks the prototype chain: with a
name from config, `'constructor' in iam.tokens` was true while
`iam.tokens.constructor` threw. Membership answers "is this token
registered?", so it now checks own keys too, matching Python's
`_IamTokenPlaceholders.__contains__`.

Co-Authored-By: Claude <noreply@anthropic.com>
The egress proxy reads a placeholder as everything between
`${e2b.identity.tokens.` and the next `}` (belt `substitute`), then looks
that name up in the registered tokens. Interpolating an unchecked name
into it corrupts the reference in both directions: `a}b` ends the
placeholder early, so the proxy mints the unrelated token `a` and leaves
`b}` as literal text, and a name carrying `{` can close its own
placeholder and open another one, minting a token the caller never
referenced.

Names are now checked where they are registered — a transform
placeholder is the only way to consume a workload token, so a name that
cannot appear in one is unusable — and again before interpolation, which
is what covers the update-network path, where any name the callable looks
up is turned into a placeholder without passing through the iam config.
Control characters are rejected with them: they cannot appear in an HTTP
header value, so the API would answer with an opaque 400.

Co-Authored-By: Claude <noreply@anthropic.com>
The placeholder spelling, the name rule it forces, and the token map the
callbacks read are one concern with a backend contract behind it, so they
get a named module next to `network`/`mcp`/`signature` rather than more
lines in sandboxApi. `utils` is the other candidate, but that is where
generic mechanism lives (runtime detection, shellQuote,
class_method_variant), not domain knowledge.

sandboxApi keeps what is network-shaped: building the transform context
around the map, and checking names as tokens are registered.

Co-Authored-By: Claude <noreply@anthropic.com>
@mishushakov
mishushakov force-pushed the network-transform-callback branch from bb0970b to 35062ec Compare August 13, 2026 14:46
@mishushakov
mishushakov merged commit 07eb9be into main Aug 13, 2026
33 of 34 checks passed
@mishushakov
mishushakov deleted the network-transform-callback branch August 13, 2026 14:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants