Skip to content

Latest commit

 

History

History
192 lines (150 loc) · 8.25 KB

File metadata and controls

192 lines (150 loc) · 8.25 KB

proxy/

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 against allowed-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 HTTP Host header 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 to 127.0.0.1:1 so the client TCP handshake gets RST and no mitmproxy-issued cert is ever presented (which would otherwise be the foothold for a curl -k bypass).
  • mitmproxy (explicit / CONNECT) at <firewall-ip>:8080 — handles the agent VM's SSH ProxyCommand, which speaks HTTP CONNECT host:22. Matches against allowed-ssh.txt.
  • dnsmasq at <firewall-ip>:53 — DNS resolver that forwards names matching allowed-dns.txt to the firewall system resolver and returns REFUSED for 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.

CA materials

./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 via security.pki.certificateFiles so the system trust store accepts mitmproxy-minted certs.
  • agent-vm-ca.pem — key + cert concatenated. ./rootcell pushes this into the firewall VM at /etc/agent-vm/agent-vm-ca.pem (mode 0600 root:root); systemd LoadCredential surfaces it to the mitmproxy services.

Delete that instance's pki/ and re-provision to rotate.

What still gets through

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.net or *.githubusercontent.com are 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.

What's still on the to-do list

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. Audit allowed-https.txt for these.
  • WebSocket frames are passed through after the upgrade — Host matches on the upgrade request, but frame contents aren't inspected.

Adding a host

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.

File formats

allowed-https.txt

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/

allowed-ssh.txt

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.

allowed-dns.txt

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.

Debugging

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.

Files in this directory

  • allowed-https.txt.defaults allowed-ssh.txt.defaults allowed-dns.txt.defaults — checked-in seed allowlists. ./rootcell copies each to <name>.txt (gitignored) on first run.
  • allowed-https.txt allowed-ssh.txt allowed-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 allow copies fresh allowlist files in. Regenerates dnsmasq's config and signals it.
  • This README.