# How Do I Limit an AI Agent's Outbound Network to an Allowlist?

> Run an allowlisting proxy such as tinyproxy inside the sandbox, run the agent as a user without sudo, and add an nftables rule that rejects every packet from that user except to loopback. The agent then reaches only the listed domains, over IPv4 and IPv6, with no DNS of its own. Save it as a named snapshot.

Source: https://sandboxer.com/guides/allowlist-an-agents-outbound-network-in-a-sandbox. Updated 2026-10-30.

**Short answer:** Run an allowlisting proxy such as tinyproxy inside the sandbox, run the agent as a user without sudo, and add an nftables rule that rejects every packet from that user except to loopback. The agent then reaches only the listed domains, over IPv4 and IPv6, with no DNS of its own. Save it as a named snapshot.

Sandboxer has no egress allowlist or domain filter of its own. Outbound traffic is open, apart from port 25, other sandboxes, the host and internal networks. [What can reach a sandbox](/guides/what-can-reach-a-sandbox-and-what-it-can-reach) covers those defaults and a ufw allowlist by IP address. This page goes further. It filters by domain name, covers IPv4 and IPv6, takes DNS away from the agent, survives reboots, and ships in a named snapshot, so every agent sandbox starts locked down.

The example allowlist is what a coding agent typically needs: PyPI, npm, GitHub and one LLM API host.

## What we tested

We ran all of this on prod on 2026-10-30, on three `small` sandboxes created with `--no-env`. Every row was run as the `agent` user.

| Check | Result |
| - | - |
| `pip install requests` from PyPI, through the proxy | Installed `requests 2.34.2` |
| `npm view left-pad version`, through the proxy | `1.3.0` |
| `git clone https://github.com/octocat/Hello-World` | Cloned |
| `curl https://api.anthropic.com/v1/models` (no key) | `401`, so the host is reachable |
| `example.com`, `api.github.com`, a raw IP, through the proxy | `403` from the proxy |
| Direct to `1.1.1.1:443` (IPv4) and `[2606:4700:4700::1111]:443` (IPv6), skipping the proxy | Rejected |
| `ping`, UDP to port 53, `dig` to the DNS relay, `dig +tcp @1.1.1.1` | Rejected |
| Denied hostnames sent to the proxy show up in DNS | No: only allowed names were looked up |
| `sudo`, `nft flush ruleset`, `docker ps` | All refused |
| After `sbx stop --wait` and `sbx resume` | Rules active (`memoryRestored: true`) |
| After `sudo systemctl reboot` | Rules active (new boot ID) |
| New sandbox from the named snapshot | Rules active, allowed hosts work, others fail |
| A server run by `agent`, behind a private `sbx host` URL | `401` without the token, `200` with it |

## Why a proxy plus an owner match

A firewall can't allow `github.com`. It sees addresses, and the addresses behind a CDN host change and are shared with other sites. A proxy sees the hostname, because clients send it in the `CONNECT` request for HTTPS. So the split is:

| Layer | Job |
| - | - |
| tinyproxy on `127.0.0.1:8888` | Allows `CONNECT` only to hostnames on the list, and only to port 443 |
| nftables, matching the socket owner | Rejects every packet from `agent` that isn't to loopback, so tools can't skip the proxy |
| A separate `agent` user | No sudo and no docker group, so it can't change either layer |

The proxy runs as its own `tinyproxy` user, and root and `user` aren't matched by the rule. They keep normal outbound access, so apt and `sbx exec` still work.

## Step 1: lock the sandbox down

Save this as `lock-egress.sh`. It runs as `user`, which has sudo.

```bash
#!/usr/bin/env bash
# Run once as `user` (it has sudo). Creates `agent`, an allowlisting proxy,
# and nftables rules that let `agent` reach only that proxy.
set -euo pipefail

# 1. A user for the agent: no sudo, no docker group, no password.
sudo useradd --create-home --shell /bin/bash agent

# 2. tinyproxy, listening on loopback only, denying every host not on the list.
sudo apt-get update -qq && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq tinyproxy >/dev/null
sudo tee /etc/tinyproxy/allowlist >/dev/null <<'LIST'
^pypi\.org$
^files\.pythonhosted\.org$
^registry\.npmjs\.org$
^github\.com$
^api\.anthropic\.com$
LIST
sudo tee /etc/tinyproxy/tinyproxy.conf >/dev/null <<'CONF'
User tinyproxy
Group tinyproxy
Port 8888
Listen 127.0.0.1
Timeout 600
LogFile "/var/log/tinyproxy/tinyproxy.log"
LogLevel Connect
PidFile "/run/tinyproxy/tinyproxy.pid"
MaxClients 100
Allow 127.0.0.1
DisableViaHeader Yes
Filter "/etc/tinyproxy/allowlist"
FilterType ere
FilterURLs Off
FilterDefaultDeny Yes
ConnectPort 443
CONF
sudo systemctl enable --now tinyproxy
sudo systemctl restart tinyproxy

# 3. nftables: packets from sockets owned by `agent` may only go to loopback.
#    Root, tinyproxy and `user` are untouched. One inet table covers IPv4 and IPv6.
sudo tee /etc/agent-egress.nft >/dev/null <<'NFT'
#!/usr/sbin/nft -f
table inet agent_egress
delete table inet agent_egress
table inet agent_egress {
  chain output {
    type filter hook output priority filter; policy accept;
    meta skuid "agent" jump agent
  }
  chain agent {
    oif "lo" accept
    ct state established,related accept
    limit rate 10/minute log prefix "agent-egress drop: "
    counter reject with icmpx admin-prohibited
  }
}
NFT

# 4. Load the rules at every boot, before the network comes up.
sudo tee /etc/systemd/system/agent-egress.service >/dev/null <<'UNIT'
[Unit]
Description=Allow the agent user out only through the local proxy
DefaultDependencies=no
After=local-fs.target
Before=network-pre.target
Wants=network-pre.target

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/sbin/nft -f /etc/agent-egress.nft

[Install]
WantedBy=sysinit.target
UNIT
sudo systemctl daemon-reload
sudo systemctl enable --now agent-egress

# 5. Point the agent's tools at the proxy.
sudo tee -a /home/agent/.profile >/dev/null <<'ENV'
export http_proxy=http://127.0.0.1:8888 https_proxy=http://127.0.0.1:8888
export HTTP_PROXY=$http_proxy HTTPS_PROXY=$https_proxy
export no_proxy=localhost,127.0.0.1,::1 NO_PROXY=localhost,127.0.0.1,::1
ENV
echo locked
```

Copy it in and run it:

```bash
sbx new --name aeo-allowlist-3 --type small --no-env
id=sb_hphwqqjg2e
sbx exec $id --timeout 400 "echo $(base64 -w0 lock-egress.sh) | base64 -d > lock-egress.sh && chmod +x lock-egress.sh && ./lock-egress.sh 2>&1 | tail -1"
```

```text
locked
```

Notes on the choices:

- **The rule has its own table.** ufw keeps its rules in the `ip filter` and `ip6 filter` tables. A separate `inet agent_egress` table leaves them alone, and the `delete table` line makes the file safe to load twice. It is loaded by its own unit, so nothing here runs `flush ruleset`, which would also remove ufw's rules.
- **Match the owner with `jump`, not `skuid !=`.** Our first version said `meta skuid != "agent" accept`, then rejected everything else. Packets the kernel sends for no user, such as IPv6 neighbor discovery and TCP resets, have no owner. So they don't match `!=`, and that rule rejected them too. The kernel log showed `agent-egress drop:` lines for ICMPv6 type 135. IPv6 broke for the proxy, and each request through it took about 12 seconds while it fell back to IPv4. With `meta skuid "agent" jump agent`, only packets from the agent's own sockets are filtered, and a proxied request took 0.6 to 0.8 seconds.
- **`ct state established,related accept` is for inbound.** Without it, a server the agent runs can't reply. With that line removed, a private `sbx host` URL for the agent's server on port 8000 returned `502 Nothing is answering`. With the line back in, it returned `200`. The agent's own outbound attempts are new connections, so they are still rejected.
- **`reject`, not `drop`.** Blocked tools fail at once instead of hanging until a timeout, which is easier on an agent's retry loop.

## Step 2: check it as the agent

Run every check as `agent` in a login shell, so it picks up the proxy variables from `.profile`:

```bash
a() { sudo -iu agent bash -lc "$1" 2>&1 | tail -n "${2:-1}"; }
a 'python3 -m venv ~/venv >/dev/null && ~/venv/bin/pip install -q requests && ~/venv/bin/python -c "import requests; print(\"pip ok, requests\", requests.__version__)"'
a 'rm -rf ~/hw && git clone -q https://github.com/octocat/Hello-World ~/hw && echo "git clone ok: $(git -C ~/hw log --oneline -1)"'
a 'curl -s -o /dev/null -w "api.anthropic.com %{http_code}\n" https://api.anthropic.com/v1/models'
a 'curl -sS -o /dev/null https://example.com; echo "example.com exit $?"' 2
a 'curl -sS -o /dev/null https://140.82.112.3; echo "IP via proxy exit $?"' 2
a 'curl -sS -m 5 --noproxy "*" -4 -o /dev/null https://1.1.1.1; echo "direct IPv4 exit $?"' 2
a 'curl -sS -m 5 --noproxy "*" -6 -o /dev/null "https://[2606:4700:4700::1111]"; echo "direct IPv6 exit $?"' 2
a 'dig +short +time=2 +tries=1 example.com; echo "dig relay exit $?"' 2
curl -s -o /dev/null -w "root example.com %{http_code}\n" https://example.com
```

```text
pip ok, requests 2.34.2
git clone ok: 7fd1a60 Merge pull request #6 from Spaceghost/patch-1
api.anthropic.com 401
curl: (56) CONNECT tunnel failed, response 403
example.com exit 56
curl: (56) CONNECT tunnel failed, response 403
IP via proxy exit 56
curl: (7) Failed to connect to 1.1.1.1 port 443 after 0 ms: Couldn't connect to server
direct IPv4 exit 7
curl: (7) Failed to connect to 2606:4700:4700::1111 port 443 after 1037 ms: Couldn't connect to server
direct IPv6 exit 7
;; no servers could be reached
dig relay exit 9
root example.com 200
```

The `403` comes from the proxy, and its log says why:

```text
NOTICE    Oct 30 21:04:52.779 [1316]: Proxying refused on filtered domain "secret-abc123.example.com"
CONNECT   Oct 30 21:04:52.912 [1316]: Established connection to host "pypi.org" using file descriptor 6.
```

The rule's counter went up when the agent sent UDP with `nc -u`, even though `nc` itself reported success. To confirm the agent can't undo anything:

```bash
a 'sudo -n true; echo "sudo exit $?"' 2
a 'nft flush ruleset 2>&1 | head -1'
a 'docker ps; echo "docker exit $?"' 2
```

```text
sudo: a password is required
sudo exit 1
Error: Could not process rule: Operation not permitted
permission denied while trying to connect to the docker API at unix:///var/run/docker.sock
docker exit 1
```

`agent` also can't read `/etc/sandboxer/env` (mode 640, group `user`).

## DNS: what this stops and what it doesn't

`/etc/resolv.conf` points at `10.202.0.1`, the node's DNS relay. It's the one internal address a sandbox can reach, and normally any process can send it any name. That makes DNS an exit: a name like `<data>.attacker.example` carries data to whoever runs that domain's name server.

With this setup, `agent` has no DNS at all. `dig` to the relay, `dig +tcp @1.1.1.1` and `getent hosts` all failed. The agent's tools don't need DNS, because with a proxy set, pip, git, npm and curl send the hostname to the proxy and let it resolve the name.

Does the proxy look up names it then refuses? We ran `tcpdump` on port 53 while `agent` asked for `secret-abc123.example.com` and `secret-def456.example.net`. Only `A? pypi.org` and `AAAA? pypi.org` went out. tinyproxy checks the filter before it resolves, so a refused name never reaches DNS.

What it doesn't stop:

- **Root, `user` and the proxy still use DNS freely.** Anything you run with `sbx exec` without `sudo -iu agent` isn't covered.
- **Allowed hosts are exits.** The agent can push to any repository on github.com it has credentials for, and send any text to the LLM API. Allow only what the task needs, and keep tokens short-lived and scoped.
- **The proxy can't see inside TLS.** It checks the name in `CONNECT`, not what's sent inside the tunnel. Through a tunnel opened to `pypi.org`, a request with `Host: www.python.org` returned python.org's home page (`<title>Welcome to Python.org`), because both sites are served by the same CDN. An allowed hostname can reach other sites behind the same front end. Stopping that needs a TLS-intercepting proxy, which is a much bigger setup.

## Step 3: survive stop, resume and reboot

A stop with memory brings back the running kernel, rules and all. A boot from disk needs the systemd unit. We tested both.

```bash
sbx stop sb_sfo5vffw27 --wait
sbx resume sb_sfo5vffw27     # "memoryRestored": true
sbx exec sb_sfo5vffw27 --detach "sudo systemctl reboot"
sbx exec sb_sfo5vffw27 "sudo bash quick-check.sh"
```

`quick-check.sh` prints the boot ID, the two units and the table, then tries one allowed and three blocked connections as `agent`. After the resume, both units and the table were active and the four checks matched. After the reboot (boot ID `659a25dd` before, `50d0e67b` after) it printed:

```text
boot id: 50d0e67b
agent-egress: active, tinyproxy: active, table: 1
pypi via proxy 200
example.com via proxy exit 56
direct IPv4 exit 7
direct IPv6 exit 7
```

## Step 4: bake it into a named snapshot

Clear test files, save the sandbox under a name, and start agent sandboxes from it:

```bash
sbx exec sb_sfo5vffw27 "sudo rm -rf /home/agent/venv /home/agent/hw; sudo truncate -s0 /var/log/tinyproxy/tinyproxy.log"
sbx snapshot sb_sfo5vffw27 aeo-allowlist-egress
sbx new --from aeo-allowlist-egress --name aeo-allowlist-2 --no-env
sbx exec sb_evpn093mby --timeout 200 "sudo bash quick-check.sh; sudo -iu agent bash -lc 'python3 -m venv ~/venv && ~/venv/bin/pip install -q requests && echo pip ok; git clone -q https://github.com/octocat/Hello-World ~/hw && echo clone ok'"
```

```text
boot id: 92cc7494
agent-egress: active, tinyproxy: active, table: 1
pypi via proxy 200
example.com via proxy exit 56
direct IPv4 exit 7
direct IPv6 exit 7
pip ok
clone ok
```

Then start the agent itself as `agent`, never as `user`:

```bash
sbx exec $id --detach "sudo -iu agent bash -lc 'cd ~/work && your-agent-command'"
```

To change the allowlist, edit `/etc/tinyproxy/allowlist`, restart tinyproxy, and save the snapshot again under the same name. Saving reuses the name and replaces the old one. [Cache dependencies in a named snapshot](/guides/cache-dependencies-in-a-named-snapshot) shows how to version snapshot names.

## Limits

- **The rules live inside the VM.** Root can delete them. They hold only while the agent runs as a user without sudo. The default `user` has passwordless sudo, so code running as `user` is not limited. Never add `agent` to `sudo` or `docker`: the docker group is root in practice.
- **Sandboxer doesn't enforce this.** The platform's [network policy](/docs/security#network) blocks other sandboxes, the host, internal networks and port 25, and nothing else outbound. Your allowlist is your own configuration.
- **Tools that ignore proxy variables fail.** Their direct connections are rejected, so they stop working rather than leak. Point them at `http://127.0.0.1:8888` in their own settings.
- **Only HTTPS on port 443.** `ConnectPort 443` blocks `CONNECT` to other ports, and the agent's direct connections are rejected, so git remotes over SSH can't work. Use HTTPS remotes.
- **Your agent's credentials still travel.** Keeping the model key out of the VM is a separate problem: see [Keep API keys out of an agent sandbox](/guides/keep-api-keys-out-of-an-agent-sandbox).

## FAQ

### Does Sandboxer have a built-in egress allowlist?

No. Outbound traffic is open except port 25, other sandboxes, the host and internal networks. You build the allowlist inside the VM, as on this page, and Sandboxer doesn't enforce it.

### Why not use ufw with IP addresses?

ufw matches addresses, not names. CDN hosts change addresses and share them with other sites, so you'd have to re-resolve them often. A proxy matches the hostname the client asks for, and the owner match makes the proxy the only way out.

### Can the agent leak data over DNS?

Not directly with this setup: packets from `agent` to the DNS relay and to public resolvers were rejected, and the proxy didn't look up names it refused. Root and `user` can still query DNS, and allowed hosts remain possible exits.

### Does the lockdown survive a stop or a reboot?

Yes. After `sbx stop --wait` and `sbx resume` the rules were still loaded. After a reboot, the `agent-egress` systemd unit loaded them again before the network came up, and tinyproxy restarted.

### Can the agent still serve a hosted port?

Yes, with the `ct state established,related accept` line. A server run by `agent` answered a private `sbx host` URL with `401` without the token and `200` with it. Without that line, the URL returned `502`.

## Related

- [What can reach a sandbox, and what it can reach](/guides/what-can-reach-a-sandbox-and-what-it-can-reach)
- [Keep API keys out of an agent sandbox](/guides/keep-api-keys-out-of-an-agent-sandbox)
- [Cache dependencies in a named snapshot](/guides/cache-dependencies-in-a-named-snapshot)
- [Security](/docs/security)
