Skip to content

Network Isolation

The guest VM has no real network interface. DNS and HTTPS are redirected to guest-side proxy binaries, forwarded to host handlers over vsock, checked against policy, and logged to the session database.

graph LR
    subgraph "Guest VM"
        APP["Application (curl, pip, npm)"]
        DNS["capsem-dns-proxy<br/>UDP/TCP :1053"]
        IPT["iptables REDIRECT<br/>:443 -> :10443"]
        NP["capsem-net-proxy<br/>TCP:10443"]
    end

    subgraph "Host"
        HDNS["DNS Proxy<br/>security rule evaluation + upstream resolver"]
        MITM["MITM Proxy<br/>TLS termination + security rule evaluation"]
        UP["Upstream server"]
    end

    APP -->|DNS :53| DNS
    DNS -->|vsock:5007| HDNS
    HDNS -->|allowed query| UP
    APP -->|HTTPS :443| IPT
    IPT -->|TCP :10443| NP
    NP -->|vsock:5002| MITM
    MITM -->|TLS| UP

No packets leave the VM through a NIC. DNS reaches the host only through vsock port 5007, and HTTPS reaches the host only through vsock port 5002.

capsem-init builds the air-gapped network stack during boot:

StepCommandPurpose
1. Loopbackip link set lo upEnable localhost
2. Dummy NICip link add dummy0 type dummyCreate fake interface
3. Assign IPip addr add 10.0.0.1/24 dev dummy0Give it a local address
4. Default routeip route add default dev dummy0All traffic routes to dummy0
5. DNS redirectiptables-nft -t nat -A OUTPUT -p udp --dport 53 -j REDIRECT --to-port 1053 plus TCPSend DNS to capsem-dns-proxy
6. HTTPS redirectiptables-nft -t nat -A OUTPUT -p tcp --dport 443 -j REDIRECT --to-port 10443Redirect HTTPS to the TLS proxy listener
7. Plain HTTP redirectiptables-nft -t nat -A OUTPUT -p tcp --dport 80 -j REDIRECT --to-port 10080 plus 3128/3713/8080/11434Redirect HTTP/dev proxy ports to the plain-HTTP listener
8. Net proxycapsem-net-proxyTCP listeners to vsock:5002 bridge
9. DNS proxycapsem-dns-proxyUDP/TCP :1053 to vsock:5007 bridge

The result: when an application resolves github.com, the query is captured on port 53, handled by capsem-dns-proxy, and resolved or denied by the host DNS handler. When an application connects to github.com:443, iptables-nft redirects the socket to 127.0.0.1:10443; capsem-net-proxy bridges the TCP connection to the host over vsock port 5002.

The host MITM proxy receives each connection on vsock:5002 and runs a full inspection pipeline:

graph TD
    A["vsock:5002 connection"] --> B["TLS ClientHello<br/>extract SNI domain"]
    B --> E["Complete TLS handshake<br/>mint leaf cert for domain"]
    E --> F["Parse HTTP request<br/>method + path + headers + bounded body capture"]
    F --> S["Build SecurityEvent<br/>HTTP + optional model roots"]
    S --> P["Preprocess plugins"]
    P --> G{"SecurityRuleSet<br/>CEL over SecurityEvent"}
    G -->|Denied or unresolved ask| H["Return 403<br/>ledger-safe log"]
    G -->|Allowed| I["Runtime materialization<br/>forward to upstream TLS"]
    I --> J["Stream response<br/>to guest"]
    J --> K["Logging plugins<br/>ledger-safe event output"]
    K --> L["Log telemetry<br/>domain, method, path, status, bytes, latency"]

The proxy mints per-domain TLS certificates signed by a static Capsem CA (ECDSA P-256, 24-hour validity). The CA is baked into the guest rootfs and trusted by the system certificate store, Python certifi, and Node.js. See MITM Proxy Architecture for implementation details.

ComponentHow it trusts the CA
System store/usr/local/share/ca-certificates/capsem-ca.crt + update-ca-certificates
Python certifiPatched bundle includes Capsem CA
Node.jsNODE_EXTRA_CA_CERTS env var
curl/wgetSSL_CERT_FILE env var
pip/requestsREQUESTS_CA_BUNDLE env var

Domains are not governed by a separate allow/block engine. DNS and HTTP parsing produce SecurityEvent fields (dns.* and http.*), then the same CEL rule rail decides allow, ask, block, preprocess, postprocess, and detection.

graph TD
    A["DNS or HTTP event parsed"] --> B["Build SecurityEvent"]
    B --> C["Configured preprocess plugins"]
    C --> D["Evaluate SecurityRuleSet by priority"]
    D --> E{"Final decision"}
    E -->|Block| F["Deny boundary<br/>log rule rows"]
    E -->|Ask| G["Wait for approval<br/>log ask state"]
    E -->|Allow| H["Materialize request<br/>log telemetry"]

Users customize policy with profile rules; organizations add constraints with corp rules or referenced enforcement/Sigma files.

[profiles.rules.allow_internal_http]
name = "allow_internal_http"
action = "allow"
match = 'http.host.matches("(^|.*\\.)internal\\.corp$")'
[profiles.rules.block_malware_dns]
name = "block_malware_dns"
action = "block"
match = 'dns.qname.matches("(^|.*\\.)malware\\.bad$")'

Corporate policy in /etc/capsem/corp.toml supplies locked negative-priority rules and can reference shared enforcement TOML or Sigma YAML rule files.

For allowed domains, security-event rules add method, path, body, model, file, process, and DNS controls through the same CEL rail. HTTP and DNS parsers attach first-party http.* and dns.* fields to SecurityEvent; enforcement and detection then use the shared rule engine.

[profiles.rules.block_repo_writes]
name = "block_repo_writes"
action = "block"
match = 'http.host == "github.com" && http.method == "POST" && http.path.matches("^/openai/")'
[profiles.rules.block_ai_provider_dns]
name = "block_ai_provider_dns"
action = "block"
match = 'dns.qname == "api.openai.com" && dns.qtype == "A"'

See Policy for the full rule reference.

Every proxied request is logged to the per-VM session.db:

ColumnContent
domainTarget domain
methodHTTP method
pathRequest path
status_codeUpstream response status
decisionFinal security decision recorded by the ledger
bytes_sentRequest body size
bytes_receivedResponse body size
duration_msEnd-to-end latency
request_body_previewCompact display field for quick scans
response_body_previewCompact display field for quick scans
matched_ruleThe security rule id that matched

Full captured HTTP, model, and MCP request/response bodies are stored in the event_body_blobs ledger table with BLAKE3 hashes, original/stored byte counts, and truncation flags. The preview columns are for UI scanability; the blob table is the forensic body source.

For AI provider traffic (Anthropic, OpenAI, Google), the proxy also parses SSE streams to extract model calls, token usage, tool calls, and estimated cost. See Session Telemetry for the full schema.

DNS queries are logged separately in dns_events with qname, qtype, rcode, decision, matched_rule, process_name, and trace_id.

ScenarioOutcomeWhy
HTTPS to blocked domain (api.openai.com)403 ForbiddenMatching block rule
HTTP port 80 (http://google.com)Redirected to the plain-HTTP listenerProfile/corp CEL rules still decide the request
Dev proxy ports (3128, 3713, 8080, 11434)Redirected to the plain-HTTP listenerLocal model/proxy traffic stays on the same security rail
Other non-standard ports (https://google.com:8443)Connection refusedOnly declared intercept ports are redirected
Direct IP (https://1.1.1.1)Connection refusedNo real NIC; dummy0 has no real route
POST to allowed domain with block rule403 ForbiddenHTTP-level rule blocks the method

Network isolation is validated by test_network.py across 7 layers. Tests are ordered low-to-high so failures pinpoint the exact broken layer.

LayerTestsWhat it validates
L1: Guest plumbingtest_dummy0_has_ip, test_dns_proxy_listening_udp, test_dns_proxy_listening_tcp, test_iptables_redirect_dns_udp_to_1053, test_iptables_redirect_dns_tcp_to_1053, test_dns_resolves_via_capsem_proxy, test_dns_nxdomain_propagates_from_upstream, test_iptables_redirect_443_to_10443dummy0 has 10.0.0.1, DNS is captured by capsem-dns-proxy, real upstream answers and NXDOMAIN propagate, HTTPS redirect rule is present
L2: Net proxytest_net_proxy_listening, test_tcp_443_reaches_proxy, test_vsock_bridge_delivers_bytescapsem-net-proxy accepts TCP on :10443, iptables redirect works, bytes flow through vsock bridge
L3: TLS handshaketest_tls_handshake_completes, test_tls_cert_from_capsem_caFull TLS to allowed domain succeeds, MITM proxy presents Capsem CA cert
L4: HTTP over MITMtest_curl_https_with_skip_verify, test_curl_verbose_diagnosticscurl -k gets HTTP response, full handshake trace captured
L5: CA trusttest_mitm_ca_cert_file_exists, test_mitm_ca_in_system_bundle, test_certifi_includes_capsem_ca, test_curl_allowed_domain_ca_trusted, test_python_urllib_https_trusted, test_ca_env_var_setCA cert file exists, in system bundle, in Python certifi, curl works without -k, Python TLS works, SSL_CERT_FILE/REQUESTS_CA_BUNDLE/NODE_EXTRA_CA_CERTS set
L6: Policy enforcementtest_denied_domain_rejected, test_post_to_random_domain_denied, test_ai_provider_domain_blocked, test_http_port_80_not_proxied, test_non_standard_port_fails, test_direct_ip_no_routeDenied domains get 403, port 80 fails, non-443 ports fail, direct IP fails
L7: Throughputtest_proxy_download_throughput100 MB download through MITM meets minimum speed threshold

Additional network tests in test_sandbox.py:

TestProperty
test_dummy_interface_existsdummy0 interface present
test_dns_resolves_via_capsem_proxyDNS resolves through the host proxy, not the old local sentinel
test_iptables_redirectREDIRECT rule active
test_net_proxy_runningcapsem-net-proxy process alive
test_dns_proxy_runningcapsem-dns-proxy process alive
test_dnsmasq_not_runningLegacy dnsmasq is absent
test_no_real_nicsOnly lo and dummy0 in /sys/class/net/
test_allowed_domainEnd-to-end HTTPS to allowed domain (5-step diagnostic)
test_denied_domainHTTPS to denied domain returns 403 or refused