Atomic Sandboxing: Building a Closed-by-Default Network Gate for Agent Code Execution in Go (#03)
I build a closed-by-default network gate in Go for gVisor agent containers. They attach at creation, stay closed, and open with one atomic nftables release after the host-side controls verify. End-to-end runs and several host checks are still pending.
Why I attach at creation and close by default
Running untrusted LLM-driven agents safely requires two distinct boundaries: compute isolation and deterministic network control. While gVisor runtime flags effectively isolate system calls from the host kernel, an agent executing arbitrary code or querying APIs still needs outbound connectivity. Without strict gating, that outbound access exposes internal subnets, local cloud metadata endpoints (such as 169.254.169.254), and peer containers to prompt-injection or SSRF attacks.
This post covers the architecture and Go implementation of a deterministic egress gate I built for gVisor sandboxes. The design principle is simple: attach the network interface at container creation, keep every packet dropped by default, and unlock access through an atomic verification pipeline. It builds on my previous gVisor hardening post, which focused strictly on runtime flags.
Core premise. The standard recipe for sandboxing network traffic is straightforward: create the container with --network none, configure the host interfaces, and run docker network connect once the environment is locked down.
In testing with gVisor's OCI runtime (runsc), this conventional approach broke down:
- Docker rejects network connect while --network none is still attached.
- If you execute disconnect none followed by connect, runsc fails to recognize the newly attached interface; inside the sandbox, only the loopback interface (lo) remains visible.
- gVisor initializes its network stack (netstack) during sandbox boot; dynamic interface hot-plugging is not reliably exposed to the guest.
To resolve this, I flipped the order:
- The container is created on a dedicated bridge network, agents-net (172.30.0.0/24), ensuring the virtual interface exists at container start.
- A low-overhead gate installed directly on the bridge drops all egress traffic before the guest process ever executes.
- Outbound connectivity is unlocked only after the container is paused, the host-side firewall rules are applied, and the resulting state is verified via read-back.
The three layers
No single packet filter is treated as an absolute boundary. The sandbox enforces three independent layers between the agent and external infrastructure:
Per-port nftables table on the host veth: Each container receives a dedicated table netdev as_port_ with ingress and egress chains bound to its host veth interface. Both chains enforce a strict drop policy until explicitly released. At release, its ingress accept rules also set the port mark.
Bridge port controls and the bridge gate: On the veth: isolated on, learning off, locked on, along with a static forwarding database (FDB) entry. On the bridge: a permanent neighbor table entry, and the agents_gate table, which passes a frame from the container only if its source MAC is in @released and the port mark is set.
Layer 3/4 filtering in DOCKER-USER and INPUT: Strict iptables rules, matched by container IP, limiting destination IPs and ports leaving agents-net or targeting the host.
The diagram shows how the three layers sit between the container and the outside. Click the boxes for details.
Show the full sectionHide the section
Layer 2: port controls
These are separate bridge/ip calls, applied in this order:
bridge link set dev <veth> isolated on
bridge link set dev <veth> learning off
bridge link set dev <veth> locked on
bridge fdb replace <mac> dev <veth> master static
ip neigh replace <ip> lladdr <mac> dev <bridge> nud permanent
- isolated on: Prevents the port from forwarding frames to other isolated ports on the same bridge, eliminating lateral container-to-container movement.
- learning off: Disables MAC learning; the bridge will not update its forwarding database based on incoming source MACs.
- locked on: Instructs the kernel to drop any frame from a source MAC that lacks an explicit static forwarding database entry.
The static FDB entry binds the container's MAC to that specific veth. The permanent neighbor entry on the bridge binds the container's IP to that MAC. Together they mitigate ARP poisoning or MAC spoofing attempts within the bridge domain.
The tool checks that /proc/sys/net/bridge/bridge-nf-call-iptables is set to 1 so bridged frames are evaluated by iptables rules. The tool validates this configuration but does not alter host-wide sysctl settings at runtime.
Layer 3 and 4: iptables
Rules are injected into DOCKER-USER and INPUT. Each rule is tagged with the comment agent-net: and uses -w to safely acquire the xtables lock:
- DNS: Restricted to UDP/TCP port 53 targeting explicit upstream resolvers (1.1.1.1, 9.9.9.9). (Note: In environments with high data-exfiltration sensitivity, point this instead to an internal inspecting DNS proxy to prevent DNS tunneling).
- Model API Endpoints: Restricted to TCP port 443 targeting resolved IPv4 addresses for approved services (e.g., api.anthropic.com, platform.claude.com). The host resolves these addresses during arming; the container has no control over destination routing.
- Default Deny: A final DROP rule for any traffic originating from the container IP that targets external or local networks.
- Host Protection (INPUT): Drops all container-initiated traffic directed at the host itself, allowing only established/related connection replies.
- Bridge Egress Control: An explicit rule tagged agent-net:gate drops bridged inter-port traffic between containers that have not completed the arming sequence.
The egress and input rules are IPv4 only. IPv6 frames fall to the policy drop of the nft chains. The endpoint names come from /etc/agent-net/endpoints.json, a root-owned installed copy that only root and the dedicated refresh_endpoints tool may write. I keep the source of the allowlist, endpoints.json on the Windows side next to the setup exes, off the host that runs the code it constrains: agent-host holds only the installed copy, for arming. When the installed file is absent, two built-in names apply (see the first caveat below).
The arming sequence
create_agent_net armexecutes a linear, 13-step pipeline organized into five phases:
Click the boxes for details.
- Preconditions (Steps 1–5): Verify process capabilities (CAP_NET_ADMIN, PID 1 network namespace). Confirm Docker daemon connectivity. Record a cryptographic hash (SHA-256) of /etc/docker/daemon.json and the Docker engine start identity. Ensure agents-net and its gate exist, and that the container uses the runsc runtime, has restart policy exactly no, is connected solely to agents-net, and is running and paused.
- Clean Slate (Step 6): Run the same name-keyed cleanup as disarm, so arm always builds from empty state. Whatever an earlier container or run of that name left (gate elements, port table, tagged iptables rules, neighbour and FDB entries) is removed first. A leftover that cannot be removed fails the arm.
- Port Identification & Default-Deny (Steps 7–8): Extract container PID, IP, and MAC from Docker inspect data. Identify the host-side veth from host state: the host kernel's view of the container's network namespace, read as root through nsenter. Nothing reported by code inside the container is used. Cross-check the veth against the agent MAC, the container IP and the agents-net bridge master; any mismatch fails the arm. Evict the MAC from the gate's @released set and load the initial default-drop nftables port table using an atomic nft -f - transaction.
- Host-Side Controls & Verification (Steps 9–10): Apply the bridge port flags, static FDB entries, and iptables rules. Immediately execute read-backs (bridge -j link show, nft list table, iptables -S) to confirm the kernel state matches the specification.
- Atomic Release (Steps 11–13): Re-verify the container is still running and paused. In a single atomic nft -f - transaction, append the allow rules to the container's port table (the ingress ones set the port mark) and add its MAC to the bridge gate's @released set, with the container name as the element comment. Re-verify the daemon identity and configuration hash. If successful, signal completion so the orchestrator can unpause the container.
Why I release last, and in one transaction
Opening the gate is an all-or-nothing operation. If any control fails or verification does not match during Steps 1–10, the container remains completely isolated. If step 11 fails, the utility triggers a fallback routine (closeAgain) to ensure the MAC is stripped from @released, resets the port table to drop, and exits with code 2. No rollback is attempted for steps 1–10: leaving an unreleased container behind closed, default-drop firewall rules is a safe failure state. The caller can safely destroy the paused container without traffic leaking.
Edge cases and caveats
These are the limits I know about. I list them as my current view, not as a closed set.
- Pinned model addresses. Design choice, fails closed: the container never picks destinations, so a CDN rotation drops new connections until refreshed.
- Workaround:
refresh_endpoints.exere-resolves and updates rules;check-gatereports drift.
- Workaround:
- No survival of a host restart. Rules vanish on reboot or
wsl --shutdown; a restarted container is dropped. I detect and remove, not prevent.- Workaround:
--restart no, recreate never restart;agent_lifecycle guardcleans up.
- Workaround:
- Tool versions. Port locking needs recent iproute2 and kernel; minimums not pinned.
- Teardown order. Disarm runs before container removal; a failed removal stops the run.
- Workaround: run it again.
- Capabilities. No
NET_ADMINby default; controls are host-side, so should hold if added.
What to take away.
- Attaching at creation and closing by default worked around what I observed with
runscanddocker network connect. - One atomic transaction opens a port, and only after read-back checks.
- The other controls stay in place after a failed arm. A closed port is the state I fail into.
What this post leaves out
I name these so they are not read as covered:
- The gVisor runtime flags (my hardening post).
- IPv6 policy beyond the default drop.
- Automatic or scheduled refreshing of resolved endpoint addresses during a long run. Only the manual
refresh_endpoints.execommand exists. - Which model endpoints an agent should be allowed to reach. The built-in default is two names, the
endpoints.jsonI supply at setup can change them, and I do not argue for either here. - Published ports bound to
127.0.0.1. That belongs to the container creation tool. - Credentials and how the agent logs in to a model.
Run it yourself
The accompanying code, the agent-net package, is a reference implementation of the closed-by-default gate in Go. Its create_agent_net command creates the agents-net network and its gate, and arms, checks and disarms one container on it. A second binary, remove_agent_net, takes the setup down. A third, refresh_endpoints, updates the pinned model addresses.
Target Environment: The commands run as root inside the agent-host distro and call docker, ip, bridge, nft, iptables and nsenter directly. They only read daemon.json (to hash it) and never write it, and they never reload or restart Docker. bridge-nf-call-iptables is read and required to be 1, never set.
Full runbook: the chapter 1 runbook takes a bare Windows + WSL2 machine to a running agent container, including building the binaries, creating agent-host, and updating the model endpoints (its sections 6, 8 and 10 cover this post's network gate).
Run this evaluation harness strictly within an isolated, disposable environment. This repository is an architectural reference pattern designed to benchmark isolation boundaries, not an off-the-shelf production system. Hardening an untrusted-code sandbox for enterprise multi-tenancy—including custom seccomp profiles, network egress filtering, and tenant quotas—requires environment-specific design.
Code references
These are the places in the code where the closed-by-default decision shows up. I describe what each one does.
Show the full sectionHide the section
In this post's package, agent-net:
- sequence.go holds the numbered step lists:
createPipeline,armPipeline(13 steps),checkGatePipeline,disarmPipelineandremovePipeline. The first failing step sets the exit code, and there is no rollback. - config.go holds the fixed names as constants: the network and its subnet, the gate table
agents_gateand its setreleased, the per-port table prefixas_port_, and the port mark bitPortMark. - gate.go holds
LoadGate, which installs the gate on the bridge device in one atomicnft -f -with policy drop on both chains. It also holdsGateRuleText, the one rule of each gate chain, andGateElementText, the set element that carries the container name as its comment. - s36_install_gate.go holds
InstallGate, the create step that installs the gate right after the network exists, before the create run verifies it. - s09_require_network.go holds
RequireNetwork. Arm uses it to refuse a network whose gate is missing or whosebridge-nf-call-iptablesis not 1, with exit 1 (GATED_UNARMED). Check-gate runs it too, but reports a missing gate asinconsistentinstead. - s34_require_paused.go holds
RequirePaused, which reads the container and requiresagents-netonly, therunscruntime, restart policyno, and running and paused. - s40_clean_slate.go holds
CleanSlate, which runs the name-keyed disarm steps in-process so that arm starts from empty state. - s10_close_port.go holds
ClosePort. It takes the MAC out ofreleased, loads the closed port table, reads it back and refuses a table that is bound to another container's live veth. - nft.go holds
LoadClosedPortandReleasePort.ReleasePortflushes the two chains, adds the accept rules and adds the MAC toreleasedin one transaction.AcceptRulesbuilds those rules, and the ingress ones set the port mark. - s15_release_port.go holds
ReleasePort, the only step that opens a port. It re-checks that the container is paused, reads everything back, and callscloseAgainon any failure after the release. - s17_check_gate.go holds
CheckInspectandCheckPortControls, the read-only report that ends inclosed,releasedorinconsistent. - s37_disarm_close_gate.go holds
DisarmCloseGate, the first deletion of a disarm: the gate closes the port before anything else of it is removed. - sequence_test.go holds
TestCreateInstallsAndVerifiesTheGate,TestArmSuccessReleasesPortLast,TestArmReleaseAddsTheMACToTheGateInTheSameTransaction,TestArmRefusesWhenTheGateIsMissingand theTestCheckGate...tests. - disarm_remove_test.go holds
TestDisarmRemovesTheMACFromTheGateBeforeTheTableandTestDisarmVerifiesTheGateSet.
According to the package README, create_agent_container and remove_agent_container of the agent-sandbox package call arm and disarm, and its guard uses released. I give no link for that package, because it has its own tag.
References
- Source of the facts in this post: the package README and code of
agent-netin the code tag. - nftables documentation: the
netdevfamily, ingress and egress hooks, and sets. - Linux
bridge(8)manual page: port flagsisolated,learningandlocked, and forwarding database entries. - Docker documentation: the
DOCKER-USERchain and user-defined bridge networks.
Show the full sectionHide the section
To reproduce, clone the tag.
git clone --branch ch01_found/p01_p02_p03 https://github.com/vectorNvolt/vectorNvolt-blog
Code for this post (tag ch01_found/p01_p02_p03): https://github.com/vectorNvolt/vectorNvolt-blog/tree/ch01_found/p01_p02_p03/chapter01/packages/agent-net