Dev.to Security 🔐 Cybersecurity 👁 0 📖 11 min read

What unshare -n leaves behind: building a boundary for an AI recon agent

Why the boundary has to hold My AI agent's sandbox passed all 13 of its isolation tests on a real GitHub runner, and the jailed process could still talk to an unauthorized program outside the jail, not just the broker

Why the boundary has to hold

My AI agent's sandbox passed all 13 of its isolation tests on a real GitHub runner, and the jailed process could still talk to an unauthorized program outside the jail, not just the broker it was meant to be limited to. It took one Unix socket.

I'm building a bug-bounty pipeline that runs entirely on GitHub Actions. An AI agent does recon, but it never touches the network itself. Every request goes through a broker that checks a signed authorization: which hosts, which methods, how many requests, until when.

That boundary is the whole safety story. An agent that reaches the network directly can hit out-of-scope systems, blow through rate limits, or follow instructions planted on a target's page. Any of those can get a hunter banned, and for me a ban ends a goal I've worked toward for years.

So the boundary has to hold when the agent is actively trying to escape, not just when it behaves. This is the record of finding a path through it, closing that path, and testing what replaced it.

The setup

The pipeline splits power three ways. The agent is untrusted and gets no network. The broker holds the policy and makes every request. The signed authorization record says what the broker may do, and only I can sign it.

The intended design gives the agent one communication path: a Unix socket to the broker. Every layer after that must agree before a byte leaves.

Two AI systems did the engineering. ChatGPT built each change as a candidate and ran what it could in its own container. Claude reviewed every candidate independently: read the diffs, reran the suites on its own Linux sandbox, and checked results against GitHub's own records. The repository is private, so the test results here are my records; the socket reproduction below is the part you can run yourself.

I controlled repository approvals. During the work, we tightened that rule to require the repository, branch, operation and exact commit (more on why below).

Why the first jail passed its tests

The first jail looked solid. The agent dropped to uid 65534 (nobody) with no-new-privs inside its own network namespace, and the runtime log recorded its effective and bounding capabilities as zero. A later probe used this variant, which adds a user namespace: -r maps the agent to root inside that namespace only, and after the final setpriv the probe again recorded zero effective and bounding capabilities:

setpriv --reuid 65534 --regid 65534 --clear-groups \
  unshare -Urn \
  setpriv --no-new-privs --bounding-set -all --inh-caps -all \
  python3 -I agent.py

The tests agreed. 18 out of 18 authorization checks passed, and 13 out of 13 isolation checks passed on a GitHub-hosted runner. The probe above added raw TCP and UDP and passed 19 out of 19, with every direct connection failing ENETUNREACH.

The problem was what the tests measured. Every egress check was an AF_INET socket: TCP, UDP, HTTP. The jail did block those. Nobody had asked what else a process can talk to without an IP stack.

The hole: filesystem Unix sockets crossed the network boundary

A network namespace isolates the IP stack. Filesystem Unix sockets live in the filesystem, and the jail still shared the host's mount namespace. Any socket file the agent could reach and connect to was a channel to a process outside the jail, bypassing the broker.

The reproduction uses two Python scripts and the launch commands below. Run it in a disposable Linux test environment with Python 3, util-linux, and permission to create the required namespaces. First, save listener.py, a host listener on a world-writable socket:

# listener.py: runs on the host, outside any jail
import os, socket, sys
path = sys.argv[1]
if os.path.exists(path):
    os.unlink(path)
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.bind(path)
os.chmod(path, 0o666)
s.listen(1)
s.settimeout(8)
conn, _ = s.accept()
print("HOST GOT:", conn.recv(100))

Second, save client.py, which will run inside the jail:

# client.py: run inside the jail by the commands below
import os, socket, sys
print("agent netns:", os.readlink("/proc/self/ns/net"))
try:
    socket.create_connection(("127.0.0.1", 9), timeout=1)
except OSError as e:
    print("inet:", e.errno)                      # 101, ENETUNREACH
u = socket.socket(socket.AF_UNIX)
u.connect(sys.argv[1])
u.sendall(b"crossed netns via unix socket")      # host receives this
print("unix: sent")

Then run the listener, and the client inside the exact jail from above:

# run as root, from a folder uid 65534 can read (chmod 755)
mkdir -p /tmp/demo && chmod 755 /tmp/demo
echo "host netns: $(readlink /proc/self/ns/net)"
python3 -I listener.py /tmp/demo/s.sock & sleep 0.5
setpriv --reuid 65534 --regid 65534 --clear-groups \
  unshare -Urn \
  setpriv --no-new-privs --bounding-set -all --inh-caps -all \
  python3 -I "$PWD/client.py" /tmp/demo/s.sock
wait

The output tells the whole story:

host netns: net:[4026531833]
agent netns: net:[4026532265]
inet: 101
unix: sent
HOST GOT: b'crossed netns via unix socket'

The agent was in a different network namespace and TCP was unreachable, yet the host got the bytes. To be precise about scope: this shows communication with a host-side listener outside the intended broker path, not direct internet access or control of the host. On a GitHub runner that matters: services like systemd-resolved and the D-Bus system bus listen on filesystem sockets, so a reachable one could become a DNS-based exfiltration channel. I didn't test that on a runner; the point is that the class of path existed.

Two details from the same probing are worth knowing. Abstract Unix sockets (the \0name kind) are scoped to the network namespace; a host listener refused the connection (ECONNREFUSED). And the original jail also shared the host's PID namespace, so the agent could see every process and its command line.

The fix: give the agent its own world, then test it

The first fix attempt masked /run, /tmp and /var/tmp with tmpfs. It passed its own check, which only scanned those directories. A full-filesystem scan from inside that jail still found a socket under the home directory, protected only by file permissions. Masking is a denylist; the next socket lives somewhere you didn't list.

The real fix builds the agent a fresh root and throws the host's tree away:

  1. New mount, network, PID, IPC and UTS namespaces (unshare --mount --net --pid --ipc --uts --fork).
  2. A tmpfs root holding only what the agent needs: read-only /usr (later narrowed further), /dev/null, /dev/zero, /dev/urandom, the agent script, and one bind-mounted broker socket.
  3. pivot_root into it, detach the old root, mount a fresh /proc for the new PID namespace.
  4. Only then drop to uid 65534 with no capabilities and no-new-privs.

The test changed too. The agent now walks its entire visible filesystem and tries to connect to every socket it finds. It passes only if exactly one socket exists, /run/broker.sock, and only it connects. It also requires ECONNREFUSED on a host abstract socket, ENOENT on a world-writable host socket, ENETUNREACH for TCP and UDP, and at most three visible processes. In practice it sees one: itself. The scan is a regression check, not a proof: it sees only what the agent can list, not inherited file descriptors or other IPC. The fresh root is the actual control.

Real CI broke the fix, which is the point of real CI

The fixed jail passed 21 out of 21 locally. On GitHub it timed out after 45 seconds and produced no output at all, because the timeout threw away the agent's stdout.

Version Where Result What was wrong
v1 Reviewer sandbox 20/21, then 21/21 Interpreter symlink pointed through /etc, missing in the jail; a test expected zero ledger spend after a legitimate request
v2 GitHub runner Timed out at 45 s Most likely the socket scan, walking all of the runner's /usr: Android SDK, .NET, Swift and more
v3 Reviewer sandbox Crashed before the jail A stray trailing comma turned the command list into a tuple; the jail also lacked /etc/ld.so.cache
v4 GitHub runner 21/21 Scan covered 1,347 entries in 0.024 s

The /usr mount was the leading explanation for the timeout. Narrowing it resolved the failure in v4, although v2's missing output prevented a definitive diagnosis. Locally, /usr held 166,207 entries and the walk took about 2 seconds. A stock runner image carries far more. Instead of raising the timeout, v3 stopped exposing /usr at all: the jail binds only the interpreter, its standard library, setpriv and the shared libraries ldd resolves. That made the scan fast and shrank what the agent can read.

v3 also added timestamped phase markers and kept partial output on timeout, so the next failure would say where it stopped. I tested that path deliberately by making the agent hang: the log showed the exact phase, and no agent process survived.

Five more layers, and what review caught

Once the new jail passed on GitHub, the same loop added five more layers. Every one was merged with CI green, and review shaped or caught something in each before merge.

Layer What it enforces What review or CI caught
Automatic CI The isolation gate runs on every push and pull request touching boundary code A checksum manifest proves files match, not that the gate wasn't weakened; workflow diffs need human reading
Loopback-only gate The broker refuses any destination that isn't loopback, even with a valid signature Review set the design before it was built: resolve once, connect to the checked IP, verify with getpeername(); two IPv6 tests could only run on GitHub
Non-root broker + kernel firewall Broker runs as uid 65532 with no capabilities; iptables and ip6tables owner rules reject its non-loopback traffic The firewall was proven in one namespace and only checked for presence in the broker's; then the broker couldn't read its own code on GitHub's 0750 home directory
Signed port limits Schema v2 signs allowed_ports; unsigned ports are rejected before DNS and at the socket The port check ran first, so the jail's "wrong host" test was blocked by the port rule and no longer tested the host rule
Firewall launcher The real launcher installs and verifies both owner rules before the broker starts, stops it if a rule disappears, and the kernel kills the broker if the launcher dies The new supervisor-kill test matched two processes, because the unshare --fork parent carries the same arguments; a red control without the parent-death signal left the broker running

The kernel firewall finding deserves a sentence. A rule installed in the wrong namespace looks correct and blocks nothing. The fix probes the rule inside the running broker's own namespace, as the broker's uid: synthetic IPv4 and IPv6 destinations are rejected for uid 65532 and reachable for root. With the rule removed, the same probe sends 12 bytes and fails the test.

The signed-port change exposed a quieter testing failure. Nothing leaked, the request was still blocked, but a check that used to guard one rule had silently started guarding a different one. Only reading the reason code exposed it.

What made the failures easier to catch

The code matters less than the habits that caught its bugs. These are the ones I'd keep for any AI-built security code.

  • Pin identities, not descriptions. Every candidate had a manifest hash; every commit was checked byte-for-byte against the tree that was tested. "The tests pass" meant nothing until it named a SHA.
  • Red controls for every check. Delete the thing being tested and confirm the test fails. The port gate, the loopback gate and the kernel firewall probe were all checked this way. A test that can't fail proves nothing.
  • Run on the real platform. My reviewer's sandbox and my own local test machine both had world-traversable home directories; GitHub's runner doesn't. Two of the bugs above only existed on the runner.
  • Use more than one machine on purpose. The reviewer's sandbox had no IPv6; my local test machine couldn't mount a fresh /proc in a nested jail. Between them and GitHub, every check ran somewhere real.
  • Fail closed, and make failure talk. Every setup failure refused to run the agent unconfined. After the timeout, failures also had to say where they stopped.
  • Approvals name the commit. Early on, a "go ahead" after a recommendation was taken as merge approval. It matched what I meant, but it could easily not have. From then on, writes to main needed the repo, branch, operation and exact SHA.
  • Archive evidence before it expires. This workflow keeps artifacts for 30 days. Batches AB-04 through AB-06 were bundled, and every included artifact checked against the SHA-256 digest GitHub recorded for it. AB-07 remains to be archived.

What is still unproven

This is a synthetic baseline, not a production qualification. Every test was written by the same process that built the code. No independent attacker and no live AI model has tried to break out, and the broker, which parses everything the agent sends, hasn't been fuzzed.

Three known engineering blockers remain before I would even consider live-target testing:

  • The loopback-only gate is still on. Removing it is its own reviewed change, and comes only after the two items below.
  • No filesystem sandbox around the broker. It can read anything world-readable on the runner. That's a remaining exposure; I've deferred it while all traffic is held to the local machine for synthetic testing.
  • No resource limits. No cgroups or seccomp yet, so a runaway agent could exhaust the runner.

The real launcher now installs both firewall rules, refuses to start the broker without them, and takes the broker down if the launcher itself is killed: in CI, the broker was gone about 3 milliseconds after a SIGKILL to the launcher. The code still refuses to send traffic anywhere but the local machine.

Evidence

All runs are in the private bounty-pipeline repo. Final main: ddbeef2b78585b88f7f7fac621860b03c86ae388.

Archive GitHub runs What it shows Bundle SHA-256 (abbreviated)
AB-04 #5–#11 Repaired jail 21/21; CI restoration; loopback gate e4929d9f…236a
AB-05 #12–#16 Non-root broker: two startup failures, then 30/30 04ffd337…901b
AB-06 #17–#19 Signed port limits: authorization 60/60, isolation 31/31 4ba16946…6f30
AB-07 #20–#22 Firewall launcher: 17/17, supervisor kill in about 3 ms to be archived

Every artifact in AB-04 through AB-06 was checked against the SHA-256 digest GitHub recorded for it. AB-07 remains to be archived. The repository is private, so these are my traceability records, not evidence readers can check themselves; the workflow keeps artifacts for 30 days, which is why they were bundled. The socket reproduction above is the part anyone can run.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.