Confine a coding agent's HTTPS to an allowlist with bwrap
Contents
Run a command (a coding agent, a build, a shell) in a bubblewrap sandbox whose only way out is HTTPS through a small proxy on the host that lets through the hosts on your list and refuses the rest.
- Tested on
- Fedora Linux 44 (KDE), kernel 7.2.13-200.fc44.x86_64, bubblewrap 0.12.0, Python 3.14.8, curl 8.18.0, Node.js v22.23.1 (2026-10-31).
- Not verified
- running a coding agent inside the sandbox. The test commands were curl, Python, bash and Node's
fetch, run from a test script. - Needs
- Linux with bubblewrap (
bwrap) and Python 3; curl for the tests. No root. Node.js for the twofetchchecks only. - Time
- not timed. The scripted re-run of the whole procedure, failure captures included, took 3 s (13:44:32 to 13:44:35); that is script speed, not the time to set this up by hand.
- Risk
- low and reversible. Nothing outside the working directory and the project directory changes, and the proxy runs as your user. The project directory is mounted read-write, so the command inside can change or delete files there.
Before you start#
- Check the tools. My run:
$ bwrap --version; python3 --version; curl --version | head -1; /usr/bin/node --version; uname -r; grep PRETTY_NAME /etc/os-release bubblewrap 0.12.0 Python 3.14.8 curl 8.18.0 (x86_64-redhat-linux-gnu) libcurl/8.18.0 OpenSSL/3.5.9 zlib/1.3.1.zlib-ng brotli/1.2.1 libidn2/2.3.8 libpsl/0.21.5 libssh/0.12.2/openssl/zlib nghttp2/1.68.0 ngtcp2/1.22.1 nghttp3/1.18.0 mit-krb5/1.22.2 OpenLDAP/2.6.15 v22.23.1 7.2.13-200.fc44.x86_64 PRETTY_NAME="Fedora Linux 44 (KDE Plasma Desktop Edition)" - Pick a working directory for the scripts. Every command below runs from it, and the proxy's files go in
run/under it. In the output below it is shown as/home/user/egress. - Placeholders, used only in step 8:
<PROJECT_DIR>: the directory the sandboxed command may read and write. It appears as/workinside.<AGENT>: the command to run in the sandbox.<INSTALL_DIR>: the directory the agent is installed in, if it is not under/usr.
- Back up or commit
<PROJECT_DIR>before you run anything in it: the sandbox mounts it read-write.
How it fits together, from the two scripts:
sandbox.shstartsbwrapwith--unshare-all, so the command gets its own network namespace that holds only loopback. Host/usrand/etcare mounted read-only;/tmp,/runand/varare empty tmpfs;HOMEis/tmp. Your real home directory is not mounted.- The only link to the host network is the proxy's unix socket, bind-mounted at
/run/egress.sock. Inside, the same Python file runs as a bridge that listens on127.0.0.1:3128and passes each connection to that socket.HTTPS_PROXY,HTTP_PROXY(and the lowercase forms) point at the bridge;NO_PROXYis empty;NODE_USE_ENV_PROXY=1makes Node's built-infetchuse them (step 6). - Because
/runis a fresh tmpfs, the host resolver's sockets under/run(systemd-resolved's varlink socket in/run/systemd/resolve) are not there, and with no network there is no DNS server to ask either. Name lookups inside fail; the proxy resolves names on the host. --unshare-user --disable-usernsstop the command from creating a new user namespace, and with it a new network namespace it could rearrange.- On the host,
egress-proxy.py serveaccepts onlyCONNECT <host>:443, checks the host against the list, and logs one JSON line per request. The log stays on the host, outside the sandbox.
Steps#
Save the proxy as
egress-proxy.py.#!/usr/bin/env python3 """Allowlisting HTTPS (CONNECT) proxy for a sandbox with no network. serve <unix-socket> <allowlist-file> host side: answers CONNECT on a unix socket bridge <unix-socket> <port> sandbox side: 127.0.0.1:<port> -> unix socket """ import ipaddress, json, os, socket, stat, sys, threading, time def pipe(a, b): try: while data := a.recv(65536): b.sendall(data) except OSError: pass finally: for s in (a, b): try: s.shutdown(socket.SHUT_RDWR) except OSError: pass def splice(a, b): threading.Thread(target=pipe, args=(a, b), daemon=True).start() pipe(b, a) def allowed(host, rules): try: if ipaddress.ip_address(host).is_link_local: return False # cloud metadata (169.254.169.254) stays refused even if listed except ValueError: pass return any(host == r or (r.startswith(".") and host.endswith(r)) for r in rules) def log(**kw): print(json.dumps({"ts": time.strftime("%Y-%m-%dT%H:%M:%S"), **kw}), flush=True) def handle(client, rules): with client: head = b"" while b"\r\n\r\n" not in head and len(head) < 16384: chunk = client.recv(4096) if not chunk: return head += chunk parts = head.split(b"\r\n", 1)[0].decode("latin-1").split() if len(parts) != 3 or parts[0] != "CONNECT": log(action="deny", reason="not CONNECT", line=" ".join(parts)[:200]) client.sendall(b"HTTP/1.1 405 Method Not Allowed\r\nContent-Length: 0\r\n\r\n") return host, _, port = parts[1].rpartition(":") host = host.strip("[]").lower() if port != "443" or not allowed(host, rules): log(action="deny", host=host, port=port) client.sendall(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n") return try: upstream = socket.create_connection((host, 443), timeout=15) except OSError as e: log(action="error", host=host, port=port, error=str(e)) client.sendall(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n") return upstream.settimeout(None) log(action="allow", host=host, port=port) client.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n") with upstream: splice(client, upstream) def serve(path, allowfile): rules = [l.strip().lower() for l in open(allowfile) if l.strip() and not l.startswith("#")] if os.path.exists(path) and stat.S_ISSOCK(os.stat(path).st_mode): os.unlink(path) # stale socket from an earlier run srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) srv.bind(path) srv.listen(64) log(action="listen", socket=path, rules=len(rules)) while True: c, _ = srv.accept() threading.Thread(target=handle, args=(c, rules), daemon=True).start() def bridge(path, port): srv = socket.create_server(("127.0.0.1", int(port))) while True: c, _ = srv.accept() u = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) u.connect(path) threading.Thread(target=splice, args=(c, u), daemon=True).start() if __name__ == "__main__": {"serve": serve, "bridge": bridge}[sys.argv[1]](*sys.argv[2:4])The same file runs on both sides:
serveon the host,bridgeinside the sandbox.Save the launcher as
sandbox.shand make it executable.#!/bin/bash # sandbox.sh <socket> <project-dir> [command...]: run a command with no network # except HTTPS through the egress proxy listening on <socket>. set -eu SOCK=$(realpath "$1"); WORK=$(realpath "$2"); shift 2 PROXY=$(realpath "$(dirname "$0")/egress-proxy.py") exec bwrap \ --unshare-all --unshare-user --disable-userns --die-with-parent \ --ro-bind /usr /usr --symlink usr/bin /bin --symlink usr/sbin /sbin \ --symlink usr/lib /lib --symlink usr/lib64 /lib64 \ --ro-bind /etc /etc --proc /proc --dev /dev \ --tmpfs /tmp --tmpfs /run --tmpfs /var \ --bind "$SOCK" /run/egress.sock --ro-bind "$PROXY" /run/egress-proxy.py \ --bind "$WORK" /work --chdir /work \ --setenv HOME /tmp \ --setenv HTTPS_PROXY http://127.0.0.1:3128 --setenv https_proxy http://127.0.0.1:3128 \ --setenv HTTP_PROXY http://127.0.0.1:3128 --setenv http_proxy http://127.0.0.1:3128 \ --setenv NO_PROXY "" --setenv NODE_USE_ENV_PROXY 1 \ -- /bin/sh -c 'python3 -I /run/egress-proxy.py bridge /run/egress.sock 3128 & sleep 0.5; exec "$@"' sh "${@:-bash}"chmod +x sandbox.shUsage:
./sandbox.sh <socket> <project-dir> [command...]. With no command it startsbash. It findsegress-proxy.pynext to itself.Write the allowlist.
mkdir -p run/work printf 'proxy.golang.org\n169.254.169.254\n' > run/allow.txtrun/allow.txtthen holds:proxy.golang.org 169.254.169.254One host per line. The match is exact (
proxy.golang.orgdoes not admitsum.golang.org). A rule that starts with a dot is a suffix rule:.example.comadmitsa.example.combut notexample.comitself. Blank lines and lines starting with#are skipped.169.254.169.254(the cloud metadata address) is listed on purpose, to show in step 6 that the proxy refuses link-local addresses even when they are on the list.Start the proxy on the host.
(python3 -I egress-proxy.py serve run/egress.sock run/allow.txt >> run/proxy.log 2>&1 & echo $! > run/proxy.pid)The first line of
run/proxy.log:{"ts": "2026-10-31T13:44:32", "action": "listen", "socket": "run/egress.sock", "rules": 2}Expected: a
listenline withrulesequal to the number of hosts inrun/allow.txt. The proxy reads the list only at start (serve()): after editing it, stop the proxy (see Undo) and start it again. Restarting it under a running sandbox was not tested; start a new sandbox after a restart.Save the test script as
tests.shnext to the two scripts, and copy it into the project directory.echo "--- allowed host"; curl -sS -o /dev/null -w "%{http_code}\n" https://proxy.golang.org/ echo "--- unlisted host"; curl -sS -o /dev/null -w "%{http_code}\n" https://example.com/ echo "--- metadata (listed)"; curl -sS -o /dev/null -w "%{http_code}\n" https://169.254.169.254/ echo "--- curl --noproxy"; curl -sS --noproxy '*' -o /dev/null -w "%{http_code}\n" https://example.com/ echo "--- python, proxy off" python3 -I -c 'import urllib.request as u; print(u.build_opener(u.ProxyHandler({})).open("https://example.com/", timeout=5).status)' 2>&1 | tail -1 echo "--- bash /dev/tcp"; bash -c 'exec 3<>/dev/tcp/192.0.2.1/443' 2>&1 | tail -1 echo "--- getent hosts"; getent hosts example.com; echo "rc=$?" echo "--- unshare -r"; unshare -r true 2>&1; echo "rc=$?" echo "--- node fetch"; node -e 'fetch("https://proxy.golang.org/").then(r => console.log(r.status))' 2>&1 echo "--- node fetch, NODE_USE_ENV_PROXY unset" env -u NODE_USE_ENV_PROXY node -e 'fetch("https://proxy.golang.org/").then(r => console.log(r.status), e => console.log(e.cause.code))' 2>&1cp tests.sh run/work/The first three checks go through the proxy. The next five try to get around it: curl and Python with the proxy switched off, a raw TCP connection to an address (
192.0.2.1is a documentation address), a DNS lookup, and a new user namespace. The last two run Node's built-infetchagainst the allowed host: once assandbox.shsets it up, once withNODE_USE_ENV_PROXYremoved.Run the tests in the sandbox.
$ ./sandbox.sh run/egress.sock run/work bash tests.sh --- allowed host 200 --- unlisted host curl: (56) CONNECT tunnel failed, response 403 000 --- metadata (listed) curl: (56) CONNECT tunnel failed, response 403 000 --- curl --noproxy curl: (6) Could not resolve host: example.com 000 --- python, proxy off urllib.error.URLError: <urlopen error [Errno -3] Temporary failure in name resolution> --- bash /dev/tcp bash: line 1: /dev/tcp/192.0.2.1/443: Network is unreachable --- getent hosts rc=2 --- unshare -r unshare: unshare failed: No space left on device rc=1 --- node fetch (node:22) [UNDICI-EHPA] Warning: EnvHttpProxyAgent is experimental, expect them to change at any time. (Use `node --trace-warnings ...` to show where the warning was created) 200 --- node fetch, NODE_USE_ENV_PROXY unset EAI_AGAINExpected, line by line:
- allowed host:
200. - unlisted host: the proxy answers
403; curl prints000because the tunnel never opened. - metadata address:
403although it is on the list (the link-local check inallowed()runs before the list). curl --noproxy '*'and Python with an empty proxy map: the name does not resolve./dev/tcpto an address:Network is unreachable, since the namespace has only loopback.getent hosts: no output, exit code 2 (not found).unshare -r:No space left on device. That is how the kernel reports a user-namespace limit (ENOSPC inunshare(2));--disable-usernssets that limit.- node fetch:
200, after a warning thatEnvHttpProxyAgent is experimental. Node printed the warning once, on the run withNODE_USE_ENV_PROXY=1. - node fetch without
NODE_USE_ENV_PROXY:EAI_AGAIN. Node ignoredHTTPS_PROXY, tried to resolve the name itself, and the sandbox has no DNS.
Any other result: see Failure modes.
- allowed host:
Read the proxy log.
{"ts": "2026-10-31T13:44:32", "action": "listen", "socket": "run/egress.sock", "rules": 2} {"ts": "2026-10-31T13:44:33", "action": "allow", "host": "proxy.golang.org", "port": "443"} {"ts": "2026-10-31T13:44:33", "action": "deny", "host": "example.com", "port": "443"} {"ts": "2026-10-31T13:44:33", "action": "deny", "host": "169.254.169.254", "port": "443"} {"ts": "2026-10-31T13:44:33", "action": "allow", "host": "proxy.golang.org", "port": "443"}The one pass of
tests.shat 13:44:33 left four lines: the allowed host, the unlisted host, the metadata address, and the allowed host again for the Nodefetchthrough the proxy. The five bypass attempts and the NodefetchwithoutNODE_USE_ENV_PROXYleft no line, because they never reached the proxy.Run your command in the sandbox. Not tested with a coding agent; the general form is:
./sandbox.sh run/egress.sock <PROJECT_DIR> <AGENT>Two things must hold, or the agent cannot start or cannot reach anything:
- The agent binary must be inside the sandbox. Only
/usr,/etc,<PROJECT_DIR>(as/work) and the proxy's two files are mounted. An agent installed under your home directory (an npm, fnm or nvm prefix,~/.local/bin,~/go/bin, SDKMAN) is not found. Add a read-only bind for its install directory tosandbox.sh, next to the/etcline:--ro-bind <INSTALL_DIR> <INSTALL_DIR> \. Bind the real directory (readlink -fthe binary first): a launcher in~/.local/bincan be a symlink into another directory, and binding only~/.local/binleaves it dangling.PATHcomes from the host, so an fnm entry under/run/userpoints into the sandbox's/run, which holds only the proxy's two files; call the agent by its full path. A JDK under~/.sdkmanfailed with exit 127 without the bind and ran with it (Field notes). - The agent must use
HTTPS_PROXY. curl and Python's urllib do. Node'sfetchdoes only withNODE_USE_ENV_PROXY=1, whichsandbox.shsets; step 6 shows200with it andEAI_AGAINwithout it (Node v22.23.1; other Node versions and Bun not tested). Java ignoresHTTPS_PROXY: a program using the JDK's own HTTP client failed withjava.net.ConnectExceptionwithout-Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=3128and got 200 with them (Field notes).[UNVERIFIED: Maven and Gradle proxy settings not tested]
Add to
run/allow.txtthe hosts the command needs (package registries, the model API), restart the proxy, then start the sandbox. A model server is reachable only if it is a host name or address on the list and answers on port 443: the proxy connects to nothing else. Forwarding a model server on another port is not covered here. The command starts withHOME=/tmpon an empty tmpfs, so it finds none of your config or credentials unless you put them in<PROJECT_DIR>; check what it needs before you put any credential there, since the command can send anything it can read to any allowed host.- The agent binary must be inside the sandbox. Only
Verify#
The same unlisted host from the host, outside the sandbox, answers. So the 403 in step 6 came from the sandbox and the proxy, not from the network:
$ curl -sS -o /dev/null -w "%{http_code}\n" https://example.com/ # host, outside the sandbox
200
Together with step 6 (allowed host 200, unlisted host 403) and the deny lines in step 7, this is the end state.
Then remove the test script's copy from the project directory:
rm run/work/tests.sh
Undo / rollback#
Stop the proxy by its pid and remove the socket (verified):
$ kill "$(cat run/proxy.pid)"; rm run/egress.sock
$ ps -p "$(cat run/proxy.pid)" >/dev/null || echo "proxy stopped"
proxy stopped
$ ss -xl | grep -c egress.sock
0
$ ls run/
allow.txt
proxy.log
proxy.pid
work
Expected: proxy stopped, 0 sockets named egress.sock listening, and run/ holding allow.txt, proxy.log, proxy.pid and work, with no egress.sock. The scripts, run/allow.txt, run/proxy.log and the stale run/proxy.pid stay; delete the working directory to remove everything. Stopping the proxy under a running sandbox was not tested.
Failure modes#
| Symptom (exact message) | Cause | Fix |
|---|---|---|
bwrap: Can't find source path .../run/egress.sock: No such file or directory, exit 1 | The proxy is not running and the socket file does not exist; bwrap cannot bind-mount it. | Start the proxy (step 4), then the sandbox. |
ConnectionRefusedError: [Errno 111] Connection refused from /run/egress-proxy.py bridge, then curl: (56) Recv failure: Connection reset by peer, exit 56 | A socket file is left from a proxy that was killed without removing it; nothing listens on it. The uncaught error also ends the bridge (from the code), so the rest of that session has no way out. | Start the proxy (serve removes a stale socket before it binds), then start a new sandbox. |
curl: (56) CONNECT tunnel failed, response 403 | The host is not on the list, the port is not 443, or the address is link-local. The log has a deny line with host and port. | Add the host to run/allow.txt and restart the proxy. Link-local addresses stay refused. |
curl: (6) Could not resolve host: example.com | The request skipped the proxy (--noproxy, or a tool that ignores HTTPS_PROXY), and the sandbox has no DNS. | Let the tool use HTTPS_PROXY. A tool without proxy support cannot reach any host from the sandbox. |
Node fetch rejects with cause.code EAI_AGAIN (step 6, last check) | NODE_USE_ENV_PROXY is not set, so fetch ignores HTTPS_PROXY and tries DNS, which the sandbox does not have. | Keep --setenv NODE_USE_ENV_PROXY 1 in sandbox.sh. |
urllib.error.URLError: <urlopen error [Errno -3] Temporary failure in name resolution> | Same as the Could not resolve host row, from Python with the proxy switched off. | Same as the Could not resolve host row. |
Captured output for the first two rows, from the same run after the rollback.
Proxy not started (the one edit: the working-directory path is shown as /home/user/egress):
$ ./sandbox.sh run/egress.sock run/work curl -sS -o /dev/null https://proxy.golang.org/ # proxy not started
bwrap: Can't find source path /home/user/egress/run/egress.sock: No such file or directory
rc=1
Stale socket, no proxy behind it:
$ ./sandbox.sh run/egress.sock run/work curl -sS -o /dev/null https://proxy.golang.org/ # stale socket, no proxy
Traceback (most recent call last):
File "/run/egress-proxy.py", line 97, in <module>
{"serve": serve, "bridge": bridge}[sys.argv[1]](*sys.argv[2:4])
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^
File "/run/egress-proxy.py", line 92, in bridge
u.connect(path)
~~~~~~~~~^^^^^^
ConnectionRefusedError: [Errno 111] Connection refused
curl: (56) Recv failure: Connection reset by peer
rc=56
Field notes#
- 2026-10-31, Fedora Linux 44 workstation (KDE), kernel 7.2.13-200.fc44.x86_64, bubblewrap 0.12.0, Python 3.14.8, curl 8.18.0, Node.js v22.23.1: A first run at 13:30 used a
sandbox.shwithoutNODE_USE_ENV_PROXYand a test script without the Node checks. The sandbox setup this manual grew out of sets that variable, because Node'sfetchignoresHTTPS_PROXYwithout it, so I added the variable and two Node checks and re-ran the whole procedure from a clean directory, in page order. The proxy logged its listen line at 13:44:32, and one pass oftests.shat 13:44:33 logged four decisions:proxy.golang.orgallowed,example.comand169.254.169.254denied, thenproxy.golang.orgallowed again for the Nodefetch, which returned 200 after an "experimental" warning. With the variable removed, the samefetchfailed withEAI_AGAINand never reached the proxy. The--noproxy, urllib,/dev/tcp,getentandunshare -rattempts all failed inside and left nothing in the log. On the host the sameexample.comrequest returned 200. I stopped the proxy by pid and removed the socket;ss -xlshowed no listener. Then I captured the two startup failures: with no socket,bwraprefuses to start; with a stale socket, the bridge dies on its first connection with a Python traceback and curl reports a reset, which reads like a network fault rather than a missing proxy. Theunshare -rmessage, "No space left on device", is the kernel's ENOSPC for a namespace limit, not a full disk. No coding agent ran in this session. A later probe the same day, with a copy ofsandbox.shcarrying two extra--ro-bindlines, for a JDK under~/.sdkmanand for~/.local/bin: without the bind thejavabinary was not found (exit 127); with it, a small JDK HTTP-client program failed withjava.net.ConnectExceptionwithout the proxy properties and printed status 200 with them. With~/.local/binbound but not~/.local/share, a launcher there that is a symlink into~/.local/sharewas dangling inside.
Notes#
- An allowed host is still a channel out. A command that may reach
github.comcan push there, and one that may reach a package registry can publish to it. Keep the list to what the command needs. - Tools that do not use
HTTPS_PROXYfail for every host, allowed or not (see Failure modes, rows 4-6). That includes Node'sfetchwithoutNODE_USE_ENV_PROXY=1. - The link-local check in
allowed()sees only literal addresses. A listed host name that resolves to a169.254.x.xaddress is still connected to, becausecreate_connectionresolves the name after the check. Read from the code; not exercised. - Only
CONNECTto port 443 passes. A plainhttp://request reaches the proxy asGETand gets405 Method Not Allowed; any other port gets403. Read fromhandle(); not exercised in this run. - If the proxy cannot connect to an allowed host it answers
502 Bad Gatewayand logs anerrorline. Read fromhandle(); not exercised. - The launcher gives the bridge a fixed 0.5 s to start before it runs the command. A command that connects sooner would fail its first request; that did not happen in this run.
- The proxy sees host names and ports only. It does not decrypt TLS, so the log holds no paths or contents.
Sources#
man bwrap, bubblewrap 0.12.0 (Fedora packagebubblewrap-0.12.0-1.fc44):--unshare-allis--unshare-user-try --unshare-ipc --unshare-pid --unshare-net --unshare-uts --unshare-cgroup-try;--disable-usernsprevents further user namespaces by settinguser.max_user_namespacesto 1;--die-with-parentkills the sandbox whenbwrap's parent dies.man 2 unshare, man-pages 6.13: ENOSPC (since Linux 4.9) when a new user namespace would exceed the nesting limit or the limit in/proc/sys/user.