The egress firewall for the agent VM. All outbound traffic from the agent VM passes through services running in the firewall VM:
- mitmproxy (transparent) at
<firewall-ip>:8081— receives TCP/443 packets that nftables NAT REDIRECT intercepts on the inter-VM link. Reads the TLS SNI from the ClientHello and matches againstallowed-https.txt. On allow, terminates TLS using a per-instance CA (the matching cert is in the agent VM's trust store) and opens a fresh TLS connection upstream — validating the upstream cert against the SNI/Host. The HTTPHostheader is then required to equal the SNI and itself be in the allowlist, so a client can't tunnel out through a shared-IP CDN by lying about Host. On deny, the upstream is redirected to127.0.0.1:1so the client TCP handshake gets RST and no mitmproxy-issued cert is ever presented (which would otherwise be the foothold for acurl -kbypass). - mitmproxy (explicit / CONNECT) at
<firewall-ip>:8080— handles the agent VM's SSHProxyCommand, which speaks HTTPCONNECT host:22. Matches againstallowed-ssh.txt. - dnsmasq at
<firewall-ip>:53— DNS resolver that forwards names matchingallowed-dns.txtto the firewall system resolver and returnsREFUSEDfor everything else.
Cleartext HTTP (TCP/80) is not proxied. The HTTP Host header is
unauthenticated — a client can claim any allowlisted name while connecting
to any IP — so a Host-header allowlist is theater. Port 80 is not
NAT-redirected; packets hit FORWARD with no rule and are dropped. Both
mitmproxy instances also kill any plain HTTP request that reaches them,
as defense-in-depth. All egress must be HTTPS or SSH.
The two mitmproxy instances share the same Python addon (mitmproxy can only run one mode per process). HTTPS is transparent by design; SSH stays explicit (hostname allowlist) and is not MITM'd — TLS intercepting SSH would break key-based auth from inside the VM.
The three allowlist files are per-instance and gitignored under
<instance-dir>/proxy/; ./rootcell seeds each from its
.defaults sibling on first run. Edit the live *.txt to customize, delete it
and re-run ./rootcell to reset to project defaults.
./rootcell generates a per-instance CA the first time it runs and persists it
under <instance-dir>/pki/ on the host. Three files:
agent-vm-ca.key— RSA 2048 private key, mode 0600. Host only.agent-vm-ca-cert.pem— public cert. Shipped into the agent VM viasecurity.pki.certificateFilesso the system trust store accepts mitmproxy-minted certs.agent-vm-ca.pem— key + cert concatenated../rootcellpushes this into the firewall VM at/etc/agent-vm/agent-vm-ca.pem(mode 0600 root:root); systemdLoadCredentialsurfaces it to the mitmproxy services.
Delete that instance's pki/ and re-provision to rotate.
Full MITM closes the SNI-vs-upstream-IP and Host-vs-SNI gaps, but the allowlist itself is the remaining attack surface:
- Shared infrastructure. Wildcards like
*.cloudfront.netor*.githubusercontent.comare valid TLS hostnames an attacker can also obtain certs for — anything they can host on those CDNs becomes a working exfil channel even with MITM. Prefer specific hostnames to wildcards; for write-able user-content services (gists, Discord webhooks, Pastebin, etc.) consider whether you need them at all. - Covert channels in legitimate traffic — timing, request order, encoded URLs to allowed read-only endpoints. Not preventable at the network layer.
The firewall is solid against accidental egress and basic deliberate exfil, but a few gaps remain:
- QUIC / HTTP/3 (UDP 443). Not intercepted; not currently dropped at the nftables level. Most clients fall back to TCP if UDP 443 fails, but a deliberate one wouldn't.
- DoH endpoints in the allowlist. Any allowed DoH host
(
cloudflare-dns.com,dns.google, etc.) doubles as a DNS bypass and an exfil channel. Auditallowed-https.txtfor these. - WebSocket frames are passed through after the upgrade — Host matches on the upgrade request, but frame contents aren't inspected.
Edit the relevant file under <instance-dir>/proxy/ and run
./rootcell --instance <name> allow from the repo root. The files are copied
into the firewall VM and dnsmasq is reloaded; mitmproxy picks up changes on its
next event (no restart). End-to-end takes ~1s.
If a host needs both DNS resolution and HTTPS access (the common case),
add it to both allowed-dns.txt and allowed-https.txt. dnsmasq
matches by suffix, mitmproxy matches by fnmatch glob plus optional
request regexes.
HTTPS (TCP/443) only — matched against the TLS SNI in the ClientHello. Cleartext HTTP is denied at the firewall, not allowlisted.
Each non-comment line is either a host fnmatch glob or a host glob followed
by a Python re regex. Host-only lines allow every HTTPS request for that
host. Scoped lines allow the TLS handshake for that host, then require the
regex to match METHOD /path?query after mitmproxy decrypts the request.
If any scoped line matches a host, one of that host's scoped regexes must match
the request. This keeps a broad host-only line from accidentally bypassing a
more specific scoped rule for the same host. Comments start with #.
# exact host
api.github.com
# one-segment wildcard
*.githubusercontent.com
# middle wildcard
bedrock-runtime.*.amazonaws.com
# HTTPS Git access for only three GitHub repositories
github.com ^(GET|POST) /rootcell-ai/(rootcell|docs|website)\.git/
Host glob format only, matched against the CONNECT host on port 22. SSH has no SNI, and this firewall does not decrypt SSH, so Git-over-SSH cannot be scoped to individual repositories with request regexes.
Plain hostnames (no globs). dnsmasq matches as a suffix, so listing
github.com lets api.github.com and codeload.github.com resolve.
A single * line forwards all DNS names; reserve that for temporary
bootstrap/testing policy.
ROOTCELL_STATE_DIR="${ROOTCELL_STATE_DIR:-$HOME/.rootcell}"
INSTANCE_DIR="$(find "$ROOTCELL_STATE_DIR/i" -maxdepth 3 -name instance.json -exec sh -c 'grep -q "\"name\": \"default\"" "$1" && dirname "$1"' sh {} \; | head -n1)"
# What's the firewall VM logging?
ssh -F "$INSTANCE_DIR/ssh/config" rootcell-firewall -- \
journalctl -u mitmproxy-explicit -u mitmproxy-transparent -u dnsmasq -f
# What is the agent sending to Bedrock?
./rootcell spy
# Is mitmproxy listening on both ports?
ssh -F "$INSTANCE_DIR/ssh/config" rootcell-firewall -- \
"ss -tln '( sport = :8080 or sport = :8081 )'"
# Is the NAT REDIRECT rule loaded?
ssh -F "$INSTANCE_DIR/ssh/config" rootcell-firewall -- \
sudo nft list table ip agent-vm-nat
# What's the agent VM seeing?
./rootcell -- curl -v https://example.com 2>&1 | head -20
# Allowlist content currently inside the VM:
ssh -F "$INSTANCE_DIR/ssh/config" rootcell-firewall -- \
"cat /etc/agent-vm/allowed-https.txt && cat /etc/agent-vm/dnsmasq-allowlist.conf"Detailed browser spy behavior, storage, privacy, and troubleshooting notes live in src/spy/README.md.
allowed-https.txt.defaultsallowed-ssh.txt.defaultsallowed-dns.txt.defaults— checked-in seed allowlists../rootcellcopies each to<name>.txt(gitignored) on first run.allowed-https.txtallowed-ssh.txtallowed-dns.txt— gitignored, user-editable live allowlists. The single source of truth at runtime.mitmproxy_addon.py— Python addon loaded by mitmdump. Reads the allowlist files from/etc/agent-vm/inside the firewall VM, with mtime-based hot reload.agent_spy.py— stdlib-only Bedrock Runtime spool shim imported by mitmproxy. It detects Bedrock by host + REST path, redacts auth headers and credential query parameters, and writes bounded JSON spool events for the TypeScript browser spy service. See src/spy/README.md for the service and UI architecture.reload.sh— runs inside the firewall VM after./rootcell allowcopies fresh allowlist files in. Regenerates dnsmasq's config and signals it.- This README.