> ## Content Index
> Fetch the complete content index at: https://vector-n-volt.ghost.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# Atomic Sandboxing: Building a Closed-by-Default Network Gate for Agent Code Execution in Go (#03)
- URL: https://vector-n-volt.ghost.io/foundation-closed-by-default-network-gate/
- Published: 2026-11-04T12:54:00.000Z
- Updated: 2026-11-06T18:47:33.000Z
- Description: 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.
- Author: Balázs Farkas
- Tags: #dev, foundation, network, gvisor, nftables, go

## 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](https://vector-n-volt.ghost.io/foundation-gvisor-hardening/), 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:

1. The container is created on a dedicated bridge network, agents-net (172.30.0.0/24), ensuring the virtual interface exists at container start.
2. A low-overhead gate installed directly on the bridge drops all egress traffic before the guest process ever executes.
3. 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.

1. 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.
2. 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.
3. 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.
4. 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.
5. 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.exe` re-resolves and updates rules; `check-gate` reports drift.
- **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 guard` cleans up.
- **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_ADMIN` by 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 `runsc` and `docker 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](https://vector-n-volt.ghost.io/foundation-gvisor-hardening/)).
- IPv6 policy beyond the default drop.
- Automatic or scheduled refreshing of resolved endpoint addresses during a long run. Only the manual `refresh_endpoints.exe` command exists.
- Which model endpoints an agent should be allowed to reach. The built-in default is two names, the `endpoints.json` I 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](https://github.com/vectorNvolt/vectorNvolt-blog/blob/ch01%5Ffound/p01%5Fp02%5Fp03/chapter01/howto/README.md?ref=vector-n-volt.ghost.io) 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](https://github.com/vectorNvolt/vectorNvolt-blog/tree/ch01%5Ffound/p01%5Fp02%5Fp03/chapter01/packages/agent-net/internal/sequence/sequence.go?ref=vector-n-volt.ghost.io) holds the numbered step lists: `createPipeline`, `armPipeline` (13 steps), `checkGatePipeline`, `disarmPipeline` and `removePipeline`. The first failing step sets the exit code, and there is no rollback.
- [config.go](https://github.com/vectorNvolt/vectorNvolt-blog/tree/ch01%5Ffound/p01%5Fp02%5Fp03/chapter01/packages/agent-net/internal/config/config.go?ref=vector-n-volt.ghost.io) holds the fixed names as constants: the network and its subnet, the gate table `agents_gate` and its set `released`, the per-port table prefix `as_port_`, and the port mark bit `PortMark`.
- [gate.go](https://github.com/vectorNvolt/vectorNvolt-blog/tree/ch01%5Ffound/p01%5Fp02%5Fp03/chapter01/packages/agent-net/internal/services/gate.go?ref=vector-n-volt.ghost.io) holds `LoadGate`, which installs the gate on the bridge device in one atomic `nft -f -` with policy drop on both chains. It also holds `GateRuleText`, the one rule of each gate chain, and `GateElementText`, the set element that carries the container name as its comment.
- [s36\_install\_gate.go](https://github.com/vectorNvolt/vectorNvolt-blog/tree/ch01%5Ffound/p01%5Fp02%5Fp03/chapter01/packages/agent-net/internal/steps/s36%5Finstall%5Fgate.go?ref=vector-n-volt.ghost.io) holds `InstallGate`, the create step that installs the gate right after the network exists, before the create run verifies it.
- [s09\_require\_network.go](https://github.com/vectorNvolt/vectorNvolt-blog/tree/ch01%5Ffound/p01%5Fp02%5Fp03/chapter01/packages/agent-net/internal/steps/s09%5Frequire%5Fnetwork.go?ref=vector-n-volt.ghost.io) holds `RequireNetwork`. Arm uses it to refuse a network whose gate is missing or whose `bridge-nf-call-iptables` is not 1, with exit 1 (`GATED_UNARMED`). Check-gate runs it too, but reports a missing gate as `inconsistent` instead.
- [s34\_require\_paused.go](https://github.com/vectorNvolt/vectorNvolt-blog/tree/ch01%5Ffound/p01%5Fp02%5Fp03/chapter01/packages/agent-net/internal/steps/s34%5Frequire%5Fpaused.go?ref=vector-n-volt.ghost.io) holds `RequirePaused`, which reads the container and requires `agents-net` only, the `runsc` runtime, restart policy `no`, and running and paused.
- [s40\_clean\_slate.go](https://github.com/vectorNvolt/vectorNvolt-blog/tree/ch01%5Ffound/p01%5Fp02%5Fp03/chapter01/packages/agent-net/internal/steps/s40%5Fclean%5Fslate.go?ref=vector-n-volt.ghost.io) holds `CleanSlate`, which runs the name-keyed disarm steps in-process so that arm starts from empty state.
- [s10\_close\_port.go](https://github.com/vectorNvolt/vectorNvolt-blog/tree/ch01%5Ffound/p01%5Fp02%5Fp03/chapter01/packages/agent-net/internal/steps/s10%5Fclose%5Fport.go?ref=vector-n-volt.ghost.io) holds `ClosePort`. It takes the MAC out of `released`, loads the closed port table, reads it back and refuses a table that is bound to another container's live veth.
- [nft.go](https://github.com/vectorNvolt/vectorNvolt-blog/tree/ch01%5Ffound/p01%5Fp02%5Fp03/chapter01/packages/agent-net/internal/services/nft.go?ref=vector-n-volt.ghost.io) holds `LoadClosedPort` and `ReleasePort`. `ReleasePort` flushes the two chains, adds the accept rules and adds the MAC to `released` in one transaction. `AcceptRules` builds those rules, and the ingress ones set the port mark.
- [s15\_release\_port.go](https://github.com/vectorNvolt/vectorNvolt-blog/tree/ch01%5Ffound/p01%5Fp02%5Fp03/chapter01/packages/agent-net/internal/steps/s15%5Frelease%5Fport.go?ref=vector-n-volt.ghost.io) holds `ReleasePort`, the only step that opens a port. It re-checks that the container is paused, reads everything back, and calls `closeAgain` on any failure after the release.
- [s17\_check\_gate.go](https://github.com/vectorNvolt/vectorNvolt-blog/tree/ch01%5Ffound/p01%5Fp02%5Fp03/chapter01/packages/agent-net/internal/steps/s17%5Fcheck%5Fgate.go?ref=vector-n-volt.ghost.io) holds `CheckInspect` and `CheckPortControls`, the read-only report that ends in `closed`, `released` or `inconsistent`.
- [s37\_disarm\_close\_gate.go](https://github.com/vectorNvolt/vectorNvolt-blog/tree/ch01%5Ffound/p01%5Fp02%5Fp03/chapter01/packages/agent-net/internal/steps/s37%5Fdisarm%5Fclose%5Fgate.go?ref=vector-n-volt.ghost.io) holds `DisarmCloseGate`, the first deletion of a disarm: the gate closes the port before anything else of it is removed.
- [sequence\_test.go](https://github.com/vectorNvolt/vectorNvolt-blog/tree/ch01%5Ffound/p01%5Fp02%5Fp03/chapter01/packages/agent-net/tests/sequence%5Ftest.go?ref=vector-n-volt.ghost.io) holds `TestCreateInstallsAndVerifiesTheGate`, `TestArmSuccessReleasesPortLast`, `TestArmReleaseAddsTheMACToTheGateInTheSameTransaction`, `TestArmRefusesWhenTheGateIsMissing` and the `TestCheckGate...` tests.
- [disarm\_remove\_test.go](https://github.com/vectorNvolt/vectorNvolt-blog/tree/ch01%5Ffound/p01%5Fp02%5Fp03/chapter01/packages/agent-net/tests/disarm%5Fremove%5Ftest.go?ref=vector-n-volt.ghost.io) holds `TestDisarmRemovesTheMACFromTheGateBeforeTheTable` and `TestDisarmVerifiesTheGateSet`.

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-net` in [the code tag](https://github.com/vectorNvolt/vectorNvolt-blog/tree/ch01%5Ffound/p01%5Fp02%5Fp03/chapter01/packages/agent-net?ref=vector-n-volt.ghost.io).
- nftables documentation: the `netdev` family, ingress and egress hooks, and sets.
- Linux `bridge(8)` manual page: port flags `isolated`, `learning` and `locked`, and forwarding database entries.
- Docker documentation: the `DOCKER-USER` chain 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](https://github.com/vectorNvolt/vectorNvolt-blog/tree/ch01%5Ffound/p01%5Fp02%5Fp03/chapter01/packages/agent-net?ref=vector-n-volt.ghost.io)