# Your Sandbox Shouldn’t Keep Its Install-Time Network Access

> Source: <https://pub.towardsai.net/your-sandbox-shouldnt-keep-its-install-time-network-access-218096916e60?source=rss----98111c9905da---4>
> Published: 2026-08-31 02:24:30+00:00

Your AI agent needed internet access to install a dependency. Why should it still have that access when it starts running code you never wrote?

Say your setup step needs pypi.org, files.pythonhosted.org, and a Git host.

The code your model writes next needs one internal API and nothing else. Both phases run in the same sandbox under the same firewall rules, because the network policy was decided before either phase existed.

If the policy stays unchanged after installation, the untrusted phase inherits the installer’s network reach and keeps it until the sandbox dies. The broader requirement wins simply because the narrower one would have broken setup.

**That’s the problem with treating network policy as part of the environment spec, alongside CPU, memory, and disk.**

For workloads where trust level changes during execution, permissions should follow the phase, not the sandbox.

Setting network policy at environment creation feels natural; it’s how we usually think about containers and VMs: give the environment a set of resources and permissions, then leave them alone until it goes away.

But agent workloads don’t stay in one trust state.

During setup, you run dependencies and tools you explicitly chose. A few minutes later, the same sandbox might execute code generated by a model or submitted by a user. Those phases can need very different access, but they sit behind the same firewall.

Keep one policy for both, and the broader permission set carries into the less-trusted phase, not because the sandbox requires it, but because the policy was tied to the sandbox instead of to the work happening inside it.

**There are a few obvious workarounds, none particularly attractive:**

**What I actually want is simpler:** keep the sandbox, change its egress policy when the phase changes, and see exactly what policy is active at each point. An egress policy defines where a sandbox can make outbound connections, and those rules don’t have to stay the same for the sandbox’s entire lifetime. That requires enforcement to live outside the workload, with the transition controlled by the system orchestrating the run.

**This is where Tensorlake’s Sandboxes are useful:** the egress policy can be replaced on a running sandbox with a single update call. I’m using Tensorlake here because the behavior is explicit enough in the documentation and SDK source to verify what actually happens, rather than filling gaps with assumptions.

Before trusting a mechanism like this in a phased design, I want to know what happens when something goes wrong. Four things matter:

**Tensorlake documents the first three directly:** the swap is atomic with no enforcement gap, sending a policy object replaces the entire policy, and a policy naming an unresolvable hostname is rejected while the previous policy stays enforced.

The fourth is more about how you design the control path than a guarantee Tensorlake gives you. The update runs through an authenticated API, so keeping that path outside the sandbox is up to how you handle credentials, code inside the sandbox could still call the API if it holds a key with enough permissions. The test is mine. The behavior it checks is not.

You can change a sandbox’s egress policy without recreating or suspending it.

The new policy applies to the running firewall in a single atomic swap, with no gap where old rules are gone and new ones haven’t taken effect.

The important detail is where enforcement happens. In the Python SDK, NetworkConfig is enforced host-side, per sandbox, not inside the guest's own network stack. Changing routes or firewall rules from inside the sandbox doesn't change the policy; doing that would require calling the same authenticated API the orchestrator uses, which makes credential placement a separate security concern.

The firewall is also stateful: established and related connections remain permitted, so changing the policy doesn’t cut off traffic already in progress.

The live update surface is deliberately limited to name, exposed ports, unauthenticated access for those ports, and egress policy. CPU, memory, and disk are fixed at creation and require a new sandbox to change. Egress policy is one of the few resource properties you can change while running, and a replacement naming an unresolvable hostname is rejected outright, leaving the sandbox on its prior policy in full. A rejected update leaves you where you already were, never somewhere weaker.

The network argument is tri-state:

You pass Result Nothing (omit network) Current policy unchanged. Update name or ports freely. A policy object The entire policy is replaced by what you sent. An explicit clear The sandbox returns to unrestricted egress.

Updates replace rather than merge, so every update has to express the complete intended policy for that phase. Clearing removes restrictions rather than imposing them, so a phase with no network at all is allow_internet_access=false with an empty allow_out, not a clear.

``` python
from tensorlake.sandbox import CLEAR_NETWORK_POLICY, NetworkConfig, Sandboxsandbox = Sandbox.connect("<sandbox-id>")# Replace the whole policy: reach api.example.com and nothing else.sandbox.update(    network=NetworkConfig(        allow_internet_access=True,        allow_out=["api.example.com"],    ))# Later: return the sandbox to unrestricted egress.sandbox.update(network=CLEAR_NETWORK_POLICY)
```

A few SDK details worth knowing if you’re writing a wrapper: Python’s None means *keep*, not *clear* (the explicit CLEAR_NETWORK_POLICY sentinel is required to clear), while TypeScript expresses the clear as network: null. Over HTTP it's a PATCH to /sandboxes/{sandbox_id}. The field is named network in the SDK but network_policy in the raw HTTP response schema, worth pinning to whichever surface you're parsing.

allow_internet_access defaults to true, which makes a policy object that only lists allow_out a DNS-permitted allowlist rather than a lockdown. I write all three fields on every update, deny_out=[] included when I mean none, to remove the guesswork.

The CLI follows the same replace-or-clear rule:

```
# Replace the policy: api.example.com plus DNS to the sandbox's resolvers.tl sbx update <sandbox-id-or-name> -A api.example.com# Replace the policy with an absolute block on outbound traffic.tl sbx update <sandbox-id-or-name> --no-internet# Clear the policy and restore unrestricted egress.tl sbx update <sandbox-id-or-name> --clear-network
```

--no-internet can't combine with a rule flag, and --clear-network can't combine with any replacement-policy flag, since each describes a whole policy on its own.

On error handling: a missing sandbox raises SandboxNotFoundError; anything else the API rejects arrives as RemoteAPIError with a status code and message; a failure to reach the API at all raises SandboxConnectionError.

That split matters for retries, since a call that never landed and a policy the server refused are different problems. The one state-specific case is 409 ("Sandbox is terminated and cannot be updated"), recoverable via restart for 48 hours, which restores from the most recent snapshot if one exists or cold-boots otherwise, re-entering Pending either way, so re-establish the phase policy explicitly rather than assume it survived.

The exact status code for an unresolvable-hostname rejection isn't clearly pinned in the schema, so branch on the message too, not just the status code, before treating a policy as permanently bad.

Tensorlake’s documentation reports two public-cloud checks of the enforcement itself: DNS resolution failing under allow_internet_access=False, and a deny_out entry blocking one destination while another stayed reachable. Those are existence proofs for specific destinations, not throughput or general enforcement measurements.

Whether this flag opens general egress or only DNS depends on allow_out. A non-empty allowlist is itself a default-deny rule, which changes what the boolean is doing:

allow_internet_access allow_out Outbound behavior true empty Everything reachable, minus whatever deny_out matches. true non-empty Default-deny: nothing but the listed destinations, DNS to the sandbox's resolvers permitted. false non-empty Default-deny again, and DNS fails unless the resolver's own IP appears in allow_out. false empty Nothing leaves the sandbox, DNS included.

Reading allow_internet_access=True, allow_out=[...] as "internet on, with an allowlist on top" gets the model backwards: you have an allowlist, with DNS permitted. The false plus non-empty row is where an afternoon disappears, since hostnames can't resolve unless the resolver's IP is also listed, or unless you list only IPv4 addresses and CIDR ranges.

allow_out accepts domains, leading-wildcard domains like *.example.com, IPv4 addresses, and CIDR ranges; deny_out accepts the same except wildcards. deny_out takes precedence on any overlap, including against the sandbox's own resolver, which is the failure mode where a correct-looking allowlist suddenly stops resolving anything. A :port suffix is accepted but doesn't make a rule port-specific. A wildcard covers subdomains but not the apex (*.example.com reaches api.example.com but not example.com itself), and hostname rules follow DNS changes, so an allowlisted CDN-backed domain keeps working as its IPs rotate.

Treat the run as a state machine whose states are policies: install → fetch → execute → deliver → sealed, one update call per arrow, a complete policy statement in every state. The phase model is mine, not a documented Tensorlake pattern, though every mechanic it relies on is documented.

Phase allow_internet_access allow_out Purpose install true Registries and source hosts Dependency installation under default-deny fetch true Input data hosts only Retrieve inputs, install-time reach already retired execute true (allowlist) or false if no network needed One endpoint, or empty Smallest surface for least-trusted code deliver true Upload/result endpoint only Output path separate from execute sealed false empty No egress at all, DNS included

The swap is atomic, so no window exists where egress goes unfiltered, and a failed transition (unresolvable hostname) leaves the previous state enforced rather than stranding the sandbox somewhere wider. What the swap doesn’t do is close what’s already open: established connections survive it, so a connection opened during install is still usable in execute. Narrowing allow_out shrinks what's reachable going forward; it doesn't retroactively make survivors safe.

```
# Setup finished. Retire the install-time surface before running generated code.sandbox.update(    network=NetworkConfig(        allow_internet_access=True,        allow_out=["api.internal.example.com"],    ))
```

Past two phases, I’d stop writing update calls by hand and keep the policy next to the phase definition instead, so the two can’t drift apart:

``` python
from tensorlake.sandbox import NetworkConfig, SandboxPHASE_ORDER = ("install", "fetch", "execute", "deliver", "sealed")PHASE_POLICY = {    "install": NetworkConfig(        allow_internet_access=True,        allow_out=["pypi.org", "files.pythonhosted.org", "github.com"],        deny_out=[],    ),    "fetch": NetworkConfig(        allow_internet_access=True,        allow_out=["inputs.internal.example.com"],        deny_out=[],    ),    "execute": NetworkConfig(        allow_internet_access=True,        allow_out=["api.internal.example.com"],        deny_out=[],    ),    "deliver": NetworkConfig(        allow_internet_access=True,        allow_out=["results.internal.example.com"],        deny_out=[],    ),    "sealed": NetworkConfig(        allow_internet_access=False,        allow_out=[],        deny_out=[],    ),}def enter_phase(sandbox: Sandbox, phase: str, current: str | None = None) -> str:    if current is not None and PHASE_ORDER.index(phase) <= PHASE_ORDER.index(current):        raise RuntimeError(f"refusing to move from {current} back to {phase}")    intended = PHASE_POLICY[phase]    info = sandbox.update(network=intended)    effective = info.network    if (        effective is None        or effective.allow_internet_access != intended.allow_internet_access        or set(effective.allow_out) != set(intended.allow_out)        or set(effective.deny_out) != set(intended.deny_out)    ):        raise RuntimeError(f"{phase}: policy did not take effect, got {effective}")    return phase
```

Every entry spells out all three fields, since leaning on the allow_internet_access default is how an intended lockdown becomes an allowlist. The check compares all three too, so a policy whose boolean or deny list drifted from the phase definition still fails loudly rather than passing on an allow_out match alone. I want that assertion because if the phase table and the sandbox ever disagree, the run should stop rather than continue under a policy nobody chose. The ordering guard enforces the declared sequence, it doesn't prove each policy is a strict subset of the one before it, but it's the cheap version of the property I actually want.

The install entry is where the real work hides. Enumerating what your build actually touches is harder than it looks, a dependency resolver can reach a CDN you never named, so the honest approach is to start strict and widen from the failures rather than guess wide and never revisit it.

Every phase change goes through an authenticated control-plane call, and on success, the response gives you the sandbox record as it stands after the update, including the current policy. I’d keep those responses: they let you answer “what was this sandbox allowed to reach at 14:32?” later, without instrumenting the workload itself.

The security benefit is straightforward, but the operational benefit is what I find more useful. After an incident, knowing a policy existed isn’t enough, you need to know which policy was actually in place at that point. A create-time configuration can’t give you that history for a sandbox that’s been running for six hours. It also gives you a boundary you can test in CI: run against a real sandbox and verify the setup-time hosts are unreachable before the untrusted phase starts.

**Connection survival** is the detail I’d pay the most attention to. Tightening the policy isn’t the same as cutting off everything already connected. If your threat model requires setup-time connections to disappear before the next phase, close them yourself.

**Egress and ingress are separate.** Changing one doesn’t change the other. Inbound access runs through exposed_ports and allow_unauthenticated_access, while the management port 9501 always requires authentication. They clear differently too: an empty exposed_ports array still leaves the management port available, while clearing the egress policy requires an explicit null.

**The API key is part of this boundary.** Updating the policy is an authenticated operation whether through SDK, CLI, or PATCH. Tensorlake API keys are project-scoped with project-member permissions, so a key that can update the sandbox can widen its network policy, which is why I'd keep the credential in the orchestrator rather than inside the environment it controls.

**Idle timeout is a separate failure mode.** timeout_secs is an idle threshold, not a wall-clock lifetime, measured against traffic through the sandbox proxy and defaulting to 600 seconds.

A named sandbox suspends and can resume under the same ID; an ephemeral one terminates. Naming a sandbox is worth deciding before a phased run, since it makes suspend/resume available, and the same update call can set name alongside the policy. I wouldn't assume a policy update resets the idle clock: the update request has no timeout_secs field, and whether a control-plane PATCH counts as the "traffic through the proxy" the timeout is defined against isn't obvious, so I'd measure that directly rather than assume it.

I'd also resume a suspended sandbox explicitly before updating it rather than assume the update handles that state transition; Sandbox.connect() doesn't auto-resume, though a request through an exposed port can auto-resume a suspended named sandbox.

If the sandbox stays at one trust level start to finish, or every phase genuinely needs the same broad access, set the policy once and leave it. Switching policies at that point just adds something to manage without reducing the sandbox’s actual reach.

If untrusted code needs no network at all, skip the transitions entirely: create the sandbox with allow_internet_access=False and an empty allow_out from the start.

And egress policy doesn’t solve a filesystem isolation problem. If untrusted code must never share an environment with credentials or artifacts left over from setup, tightening network access isn’t enough, the policy controls where the sandbox connects, not what’s already sitting inside it. Use separate environments for that instead.

Permission lifetime is a design choice. Tie it to the sandbox’s lifetime, and you carry the combined network access every phase needs for as long as the sandbox exists.

Once you can change the policy while running, the egress policy can match the phase instead. List what setup actually needs rather than opening access broadly, replace that policy before untrusted code runs, and check the returned policy at each transition.

The version I’d actually use is simple: keep policies in a phase dictionary, one update per boundary, refuse to move back to a broader phase, and fail loudly if the policy that comes back isn’t the one you expected.

It isn’t much code, but it changes the security model. The sandbox no longer carries the installer’s network reach for its entire lifetime. It only has it while the installer needs it.

[Your Sandbox Shouldn’t Keep Its Install-Time Network Access](https://pub.towardsai.net/your-sandbox-shouldnt-keep-its-install-time-network-access-218096916e60) was originally published in [Towards AI](https://pub.towardsai.net) on Medium, where people are continuing the conversation by highlighting and responding to this story.
