Manual

Confine a coding agent's HTTPS to an allowlist with bwrap

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 two fetch checks 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 /work inside.
    • <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.sh starts bwrap with --unshare-all, so the command gets its own network namespace that holds only loopback. Host /usr and /etc are mounted read-only; /tmp, /run and /var are empty tmpfs; HOME is /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 on 127.0.0.1:3128 and passes each connection to that socket. HTTPS_PROXY, HTTP_PROXY (and the lowercase forms) point at the bridge; NO_PROXY is empty; NODE_USE_ENV_PROXY=1 makes Node's built-in fetch use them (step 6).
  • Because /run is 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-userns stop the command from creating a new user namespace, and with it a new network namespace it could rearrange.
  • On the host, egress-proxy.py serve accepts only CONNECT <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#

  1. 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: serve on the host, bridge inside the sandbox.

  2. Save the launcher as sandbox.sh and 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.sh
    

    Usage: ./sandbox.sh <socket> <project-dir> [command...]. With no command it starts bash. It finds egress-proxy.py next to itself.

  3. Write the allowlist.

    mkdir -p run/work
    printf 'proxy.golang.org\n169.254.169.254\n' > run/allow.txt
    

    run/allow.txt then holds:

    proxy.golang.org
    169.254.169.254
    

    One host per line. The match is exact (proxy.golang.org does not admit sum.golang.org). A rule that starts with a dot is a suffix rule: .example.com admits a.example.com but not example.com itself. 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.

  4. 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 listen line with rules equal to the number of hosts in run/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.

  5. Save the test script as tests.sh next 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>&1
    
    cp 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.1 is a documentation address), a DNS lookup, and a new user namespace. The last two run Node's built-in fetch against the allowed host: once as sandbox.sh sets it up, once with NODE_USE_ENV_PROXY removed.

  6. 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_AGAIN
    

    Expected, line by line:

    • allowed host: 200.
    • unlisted host: the proxy answers 403; curl prints 000 because the tunnel never opened.
    • metadata address: 403 although it is on the list (the link-local check in allowed() runs before the list).
    • curl --noproxy '*' and Python with an empty proxy map: the name does not resolve.
    • /dev/tcp to 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 in unshare(2)); --disable-userns sets that limit.
    • node fetch: 200, after a warning that EnvHttpProxyAgent is experimental. Node printed the warning once, on the run with NODE_USE_ENV_PROXY=1.
    • node fetch without NODE_USE_ENV_PROXY: EAI_AGAIN. Node ignored HTTPS_PROXY, tried to resolve the name itself, and the sandbox has no DNS.

    Any other result: see Failure modes.

  7. 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.sh at 13:44:33 left four lines: the allowed host, the unlisted host, the metadata address, and the allowed host again for the Node fetch through the proxy. The five bypass attempts and the Node fetch without NODE_USE_ENV_PROXY left no line, because they never reached the proxy.

  8. 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 to sandbox.sh, next to the /etc line: --ro-bind <INSTALL_DIR> <INSTALL_DIR> \. Bind the real directory (readlink -f the binary first): a launcher in ~/.local/bin can be a symlink into another directory, and binding only ~/.local/bin leaves it dangling. PATH comes from the host, so an fnm entry under /run/user points into the sandbox's /run, which holds only the proxy's two files; call the agent by its full path. A JDK under ~/.sdkman failed 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's fetch does only with NODE_USE_ENV_PROXY=1, which sandbox.sh sets; step 6 shows 200 with it and EAI_AGAIN without it (Node v22.23.1; other Node versions and Bun not tested). Java ignores HTTPS_PROXY: a program using the JDK's own HTTP client failed with java.net.ConnectException without -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=3128 and got 200 with them (Field notes). [UNVERIFIED: Maven and Gradle proxy settings not tested]

    Add to run/allow.txt the 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 with HOME=/tmp on 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.

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)CauseFix
bwrap: Can't find source path .../run/egress.sock: No such file or directory, exit 1The 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 56A 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 403The 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.comThe 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.sh without NODE_USE_ENV_PROXY and a test script without the Node checks. The sandbox setup this manual grew out of sets that variable, because Node's fetch ignores HTTPS_PROXY without 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 of tests.sh at 13:44:33 logged four decisions: proxy.golang.org allowed, example.com and 169.254.169.254 denied, then proxy.golang.org allowed again for the Node fetch, which returned 200 after an "experimental" warning. With the variable removed, the same fetch failed with EAI_AGAIN and never reached the proxy. The --noproxy, urllib, /dev/tcp, getent and unshare -r attempts all failed inside and left nothing in the log. On the host the same example.com request returned 200. I stopped the proxy by pid and removed the socket; ss -xl showed no listener. Then I captured the two startup failures: with no socket, bwrap refuses 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. The unshare -r message, "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 of sandbox.sh carrying two extra --ro-bind lines, for a JDK under ~/.sdkman and for ~/.local/bin: without the bind the java binary was not found (exit 127); with it, a small JDK HTTP-client program failed with java.net.ConnectException without the proxy properties and printed status 200 with them. With ~/.local/bin bound but not ~/.local/share, a launcher there that is a symlink into ~/.local/share was dangling inside.

Notes#

  • An allowed host is still a channel out. A command that may reach github.com can 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_PROXY fail for every host, allowed or not (see Failure modes, rows 4-6). That includes Node's fetch without NODE_USE_ENV_PROXY=1.
  • The link-local check in allowed() sees only literal addresses. A listed host name that resolves to a 169.254.x.x address is still connected to, because create_connection resolves the name after the check. Read from the code; not exercised.
  • Only CONNECT to port 443 passes. A plain http:// request reaches the proxy as GET and gets 405 Method Not Allowed; any other port gets 403. Read from handle(); not exercised in this run.
  • If the proxy cannot connect to an allowed host it answers 502 Bad Gateway and logs an error line. Read from handle(); 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.

Filed under: ai-agents, security, linux

Sources#

  • man bwrap, bubblewrap 0.12.0 (Fedora package bubblewrap-0.12.0-1.fc44): --unshare-all is --unshare-user-try --unshare-ipc --unshare-pid --unshare-net --unshare-uts --unshare-cgroup-try; --disable-userns prevents further user namespaces by setting user.max_user_namespaces to 1; --die-with-parent kills the sandbox when bwrap'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.