Give setup the access it needs, then lock the sandbox down before untrusted code runs, without restarting it.
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.
The permission window is wider than the work
![Photo from AI]https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/i6dgpjktl7llp7fsi6z8.png)
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:
- Separate environments. Works, but you move state between them and pay the setup cost twice.
- Enforce the policy from inside the sandbox. Puts the control mechanism in the same environment you're trying to restrict.
- Recreate the sandbox between phases. Keeps the security boundary, but throws away the state you just built.
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.
What a live policy change has to guarantee to be worth using
Before trusting a mechanism like this in a phased design, I want to know what happens when something goes wrong. Four things matter:
Atomicity — no moment where old rules have disappeared but new ones aren't active yet. Failure containment — a bad policy leaves the previous one in place. An external control path — the transition is driven by the orchestrator, not the workload. Clear replacement semantics — updates replace rather than merge, so permissions can't quietly accumulate across phases.
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.
How the policy swap behaves on a running sandbox
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, 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.
from tensorlake.sandbox import CLEAR_NETWORK_POLICY, NetworkConfig, Sandbox
sandbox = 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.
What allow_internet_access actually controls once allow_out is non-empty
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.
A policy state machine for a phased agent run
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:
from tensorlake.sandbox import NetworkConfig, Sandbox
PHASE_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.
Each transition returns a record you can keep
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.
What the swap does not do for you
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.
When this is the wrong tool
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.
The takeaway
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.







Top comments (0)